diff --git a/.gitignore b/.gitignore index c2065bc..24db837 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + +### Security ### +/src/main/resources/application-aws.properties +/src/main/resources/application.properties \ No newline at end of file diff --git a/build.gradle b/build.gradle index eeeb325..6b39ae7 100644 --- a/build.gradle +++ b/build.gradle @@ -25,14 +25,41 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' //jwt + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' //jwt + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' //jwt compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + //test 용 데이터베이스 + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'com.h2database:h2' + + //swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + //s3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' } tasks.named('test') { useJUnitPlatform() } + +clean { + delete file('src/main/generated') +} + diff --git a/src/main/java/apptive/devlog/DevlogApplication.java b/src/main/java/apptive/devlog/DevlogApplication.java index fa7035e..7d5a440 100644 --- a/src/main/java/apptive/devlog/DevlogApplication.java +++ b/src/main/java/apptive/devlog/DevlogApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class DevlogApplication { public static void main(String[] args) { diff --git a/src/main/java/apptive/devlog/comment/controller/CommentController.java b/src/main/java/apptive/devlog/comment/controller/CommentController.java new file mode 100644 index 0000000..31dfca6 --- /dev/null +++ b/src/main/java/apptive/devlog/comment/controller/CommentController.java @@ -0,0 +1,60 @@ +package apptive.devlog.comment.controller; + +import apptive.devlog.comment.dto.CommentRequest; +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.comment.service.CommentService; +import apptive.devlog.member.dto.MemberDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users/me") +public class CommentController { + + private final CommentService commentService; + + @PostMapping("/post/{id}/comment") + public ResponseEntity createComment(@Valid @RequestBody CommentRequest comment, + @PathVariable Long id, + @AuthenticationPrincipal MemberDetails member) { + CommentResponse response = commentService.saveComment(comment, id, member.getUsername()); + + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/post/{postId}/comment/{commentId}") + public ResponseEntity createReComment(@Valid @RequestBody CommentRequest comment, + @PathVariable Long postId, @PathVariable Long commentId, + @AuthenticationPrincipal MemberDetails member) { + CommentResponse response = commentService.saveReComment(comment, postId, commentId, member.getUsername()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @DeleteMapping("/comment/{id}") + public ResponseEntity> deleteComment(@PathVariable Long id, + @AuthenticationPrincipal MemberDetails member) { + commentService.deleteComment(id, member.getUsername()); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(Map.of("message", "댓글이 삭제되었습니다.")); + } + + + @PatchMapping("/comment/{id}") + public ResponseEntity> updateComment(@Valid @RequestBody CommentRequest request, @PathVariable Long id, + @AuthenticationPrincipal MemberDetails member) { + commentService.updateComment(request, id, member.getUsername()); + + return ResponseEntity.status(HttpStatus.OK).body(Map.of("message", "댓글이 수정되었습니다")); + } +} diff --git a/src/main/java/apptive/devlog/comment/dto/CommentRequest.java b/src/main/java/apptive/devlog/comment/dto/CommentRequest.java new file mode 100644 index 0000000..6b882f0 --- /dev/null +++ b/src/main/java/apptive/devlog/comment/dto/CommentRequest.java @@ -0,0 +1,18 @@ +package apptive.devlog.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CommentRequest { + + @NotBlank + private String content; + + +} diff --git a/src/main/java/apptive/devlog/comment/dto/CommentResponse.java b/src/main/java/apptive/devlog/comment/dto/CommentResponse.java new file mode 100644 index 0000000..5c49598 --- /dev/null +++ b/src/main/java/apptive/devlog/comment/dto/CommentResponse.java @@ -0,0 +1,49 @@ +package apptive.devlog.comment.dto; + + +import apptive.devlog.domain.Comment; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommentResponse { + + private Long id; + private String author; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List reComments = new ArrayList<>(); + private Boolean isDeleted; + + + + public CommentResponse(String author, Comment comment, List reComments) { + this.author = author; + this.id = comment.getId(); + this.content = comment.getContent(); + this.createdAt = comment.getCreatedAt(); + this.updatedAt = comment.getUpdatedAt(); + this.isDeleted = comment.isDeleted(); + this.reComments = reComments; + } + + public CommentResponse(String author, Comment comment) { + this.author = author; + this.id = comment.getId(); + this.content = comment.getContent(); + this.createdAt = comment.getCreatedAt(); + this.updatedAt = comment.getUpdatedAt(); + this.isDeleted = comment.isDeleted(); + } + +} + diff --git a/src/main/java/apptive/devlog/comment/dto/ReCommentResponse.java b/src/main/java/apptive/devlog/comment/dto/ReCommentResponse.java new file mode 100644 index 0000000..0ba81c4 --- /dev/null +++ b/src/main/java/apptive/devlog/comment/dto/ReCommentResponse.java @@ -0,0 +1,22 @@ +package apptive.devlog.comment.dto; + +import apptive.devlog.domain.Comment; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ReCommentResponse { + + private Long id; + private String author; + private String content; + private Long parentId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/apptive/devlog/comment/exception/BadCommentRequestException.java b/src/main/java/apptive/devlog/comment/exception/BadCommentRequestException.java new file mode 100644 index 0000000..07f09d5 --- /dev/null +++ b/src/main/java/apptive/devlog/comment/exception/BadCommentRequestException.java @@ -0,0 +1,7 @@ +package apptive.devlog.comment.exception; + +public class BadCommentRequestException extends RuntimeException { + public BadCommentRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/comment/exception/CommentExceptionHandler.java b/src/main/java/apptive/devlog/comment/exception/CommentExceptionHandler.java new file mode 100644 index 0000000..4c2701a --- /dev/null +++ b/src/main/java/apptive/devlog/comment/exception/CommentExceptionHandler.java @@ -0,0 +1,23 @@ +package apptive.devlog.comment.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class CommentExceptionHandler { + + @ExceptionHandler(BadCommentRequestException.class) + public ResponseEntity> handleBadComment(BadCommentRequestException ex) { + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message", ex.getMessage())); + } + + @ExceptionHandler(NotFoundCommentException.class) + public ResponseEntity> handleNotFoundComment(NotFoundCommentException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } +} diff --git a/src/main/java/apptive/devlog/comment/exception/NotFoundCommentException.java b/src/main/java/apptive/devlog/comment/exception/NotFoundCommentException.java new file mode 100644 index 0000000..2ba6cce --- /dev/null +++ b/src/main/java/apptive/devlog/comment/exception/NotFoundCommentException.java @@ -0,0 +1,7 @@ +package apptive.devlog.comment.exception; + +public class NotFoundCommentException extends RuntimeException { + public NotFoundCommentException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/comment/repository/CommentRepository.java b/src/main/java/apptive/devlog/comment/repository/CommentRepository.java new file mode 100644 index 0000000..9af4990 --- /dev/null +++ b/src/main/java/apptive/devlog/comment/repository/CommentRepository.java @@ -0,0 +1,22 @@ +package apptive.devlog.comment.repository; + +import apptive.devlog.comment.dto.ReCommentResponse; +import apptive.devlog.domain.Comment; +import apptive.devlog.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + @Query("select count (c) from Comment c where c.parent = :parent") + long countChild(Comment parent); + + @Query("select new apptive.devlog.comment.dto.ReCommentResponse " + + "(c.id, c.member.nickname, c.content,c.parent.id, c.createdAt, c.updatedAt) from Comment c where c.parent in :parents") + List findReComments(List parents); + + +} diff --git a/src/main/java/apptive/devlog/comment/repository/QCommentRepository.java b/src/main/java/apptive/devlog/comment/repository/QCommentRepository.java new file mode 100644 index 0000000..657654b --- /dev/null +++ b/src/main/java/apptive/devlog/comment/repository/QCommentRepository.java @@ -0,0 +1,48 @@ +package apptive.devlog.comment.repository; + + +import apptive.devlog.domain.Comment; +import apptive.devlog.domain.QComment; +import apptive.devlog.domain.QMember; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +import static apptive.devlog.domain.QComment.*; +import static apptive.devlog.domain.QMember.*; + +@Repository +public class QCommentRepository { + + private final JPAQueryFactory queryFactory; + + public QCommentRepository(EntityManager em) { + queryFactory = new JPAQueryFactory(em); + } + + public Page findParentComment(Long id, Pageable pageable) { + List comments = queryFactory + .selectFrom(comment) + .join(comment.member, member).fetchJoin() + .where(comment.post.id.eq(id).and(comment.parent.id.isNull())) + .orderBy(comment.createdAt.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long count = Optional.ofNullable(queryFactory + .select(comment.count()) + .from(comment) + .where(comment.post.id.eq(id).and(comment.parent.id.isNull())) + .fetchOne()).orElse(0L); + + return new PageImpl<>(comments, pageable, count); + } +} diff --git a/src/main/java/apptive/devlog/comment/service/CommentService.java b/src/main/java/apptive/devlog/comment/service/CommentService.java new file mode 100644 index 0000000..907644d --- /dev/null +++ b/src/main/java/apptive/devlog/comment/service/CommentService.java @@ -0,0 +1,90 @@ +package apptive.devlog.comment.service; + +import apptive.devlog.comment.dto.CommentRequest; +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.comment.exception.BadCommentRequestException; +import apptive.devlog.comment.exception.NotFoundCommentException; +import apptive.devlog.comment.repository.CommentRepository; +import apptive.devlog.comment.repository.QCommentRepository; +import apptive.devlog.domain.Comment; +import apptive.devlog.domain.Member; +import apptive.devlog.domain.Post; +import apptive.devlog.member.exception.NotFoundMemberException; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.post.exception.BadPostRequestException; +import apptive.devlog.post.exception.NotFoundPostException; +import apptive.devlog.post.repository.PostRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Transactional +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final MemberRepository memberRepository; + + + public CommentResponse saveComment(CommentRequest request, Long id, String email) { + Member findMember = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundMemberException("존재하는 회원이 아닙니다.")); + Post findPost = postRepository.findById(id).orElseThrow(() -> new NotFoundPostException("게시글이 존재하지 않습니다")); + + Comment saved = commentRepository.save(new Comment(request.getContent(), findMember, findPost, null)); + + return new CommentResponse(findMember.getNickname(), saved); + } + + public CommentResponse saveReComment(CommentRequest request, Long postId, Long commentId, String email) { + Comment parentComment = commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundCommentException("댓글이 존재하지 않습니다.")); + if (parentComment.getParent() != null) throw new BadCommentRequestException("대댓글에는 대댓글 작성이 불가능합니다."); + if (parentComment.isDeleted()) throw new BadCommentRequestException("삭제된 댓글에는 대댓글 작성이 불가능합니다."); + + Member findMember = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundMemberException("존재하는 회원이 아닙니다.")); + Post findPost = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundPostException("게시글이 존재하지 않습니다")); + + Comment saved = commentRepository.save(new Comment(request.getContent(), findMember, findPost, parentComment)); + + return new CommentResponse(findMember.getNickname(), saved); + } + + public void updateComment(CommentRequest request, Long id, String email) { + Comment findComment = commentRepository.findById(id) + .orElseThrow(() -> new NotFoundCommentException("댓글이 존재하지 않습니다")); + if (findComment.isDeleted()) + throw new BadCommentRequestException("삭제된 댓글입니다."); + if (!findComment.getMember().getEmail().equals(email)) + throw new BadCommentRequestException("본인이 작성한 댓글만 삭제할 수 있습니다."); + + findComment.changeContent(request.getContent()); + } + + public void deleteComment(Long id, String email) { + Comment findComment = commentRepository.findById(id) + .orElseThrow(() -> new NotFoundCommentException("댓글이 존재하지 않습니다.")); + if (!findComment.getMember().getEmail().equals(email)) + throw new BadCommentRequestException("본인이 작성한 댓글만 삭제할 수 있습니다."); + + if (findComment.getParent() != null) { + commentRepository.delete(findComment); + Comment parent = findComment.getParent(); + if (parent.isDeleted() && commentRepository.countChild(parent) == 0) commentRepository.delete(parent); + } + else if (commentRepository.countChild(findComment) == 0) { + commentRepository.delete(findComment); + } + else { + findComment.softDelete(); + } + } + +} diff --git a/src/main/java/apptive/devlog/config/CorsMvcConfig.java b/src/main/java/apptive/devlog/config/CorsMvcConfig.java new file mode 100644 index 0000000..d8d2742 --- /dev/null +++ b/src/main/java/apptive/devlog/config/CorsMvcConfig.java @@ -0,0 +1,15 @@ +package apptive.devlog.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000"); + } +} diff --git a/src/main/java/apptive/devlog/config/S3Config.java b/src/main/java/apptive/devlog/config/S3Config.java new file mode 100644 index 0000000..218b8e0 --- /dev/null +++ b/src/main/java/apptive/devlog/config/S3Config.java @@ -0,0 +1,32 @@ +package apptive.devlog.config; + +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${s3.credentials.access-key}") + public String accessKey; + + @Value("${s3.credentials.secret-key}") + private String secretKey; + + @Value("${s3.credentials.region}") + private String region; + + @Bean + public AmazonS3Client s3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + + return (AmazonS3Client) AmazonS3Client.builder() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} diff --git a/src/main/java/apptive/devlog/config/WebSecurityConfig.java b/src/main/java/apptive/devlog/config/WebSecurityConfig.java new file mode 100644 index 0000000..89ea9fb --- /dev/null +++ b/src/main/java/apptive/devlog/config/WebSecurityConfig.java @@ -0,0 +1,109 @@ +package apptive.devlog.config; + +import apptive.devlog.member.jwt.CustomLogoutFilter; +import apptive.devlog.member.jwt.JWTFilter; +import apptive.devlog.member.jwt.JWTUtil; +import apptive.devlog.member.jwt.LoginFilter; +import apptive.devlog.member.repository.RefreshRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http.cors((cors) -> cors + .configurationSource(new CorsConfigurationSource() { + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + CorsConfiguration con = new CorsConfiguration(); + con.setAllowedOrigins(Collections.singletonList("http://localhost:3000")); //프론트엔드 주소 허용 + con.setAllowedMethods(Collections.singletonList("*")); // 모든 GET,POST .. 요청 허용 + con.setAllowCredentials(true); //쿠키 포함 요청 허용 + con.setAllowedHeaders(Collections.singletonList("*")); // 클라이언트가 요청할 수 있는 헤더 + + con.setExposedHeaders(Arrays.asList("Set-Cookie", "access")); + //서버가 응답한 헤더중 클라이언트가 접근할 수 있는 헤더 + //Set-Cookie는 브라우저가 쿠키 저장소에 자동으로 저장하기 위한 용도 + return con; + } + })); + + http.csrf((auth) -> auth.disable()); //jwt방식을 사용하기 때문에 csrf 공격에 취약하지 않음 + http.formLogin((auth)-> auth.disable()); + http.httpBasic((auth)->auth.disable()); + // jwt방식을 사용할때는 위 3가지를 disable 시켜줘야한다. + + http.authorizeHttpRequests((auth)-> + auth.requestMatchers(permitPaths).permitAll() + .requestMatchers("/users/me/**").authenticated() + .requestMatchers("/home").hasRole("MEMBER")//ROLE_MEMBER + .anyRequest().authenticated()); + + http.sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + //JWT를 통한 인증/인가를 위해서 세션을 STATELESS 상태로 설정하는 것이 중요하다. + + http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); //로그인 필터 전에 등록 + + http.addFilterAt( + new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil,refreshRepository), + UsernamePasswordAuthenticationFilter.class); + + http.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), + LogoutFilter.class); + + return http.build(); + } + + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + private final String[] permitPaths = {"/login", "/users", "/reissue", "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/webjars/**", + "/users/post/*", + "/users/comment/*", + "/upload", + "/file/upload", + "/users/*/post/**"}; + + +} diff --git a/src/main/java/apptive/devlog/domain/BaseTimeEntity.java b/src/main/java/apptive/devlog/domain/BaseTimeEntity.java new file mode 100644 index 0000000..fb2d368 --- /dev/null +++ b/src/main/java/apptive/devlog/domain/BaseTimeEntity.java @@ -0,0 +1,25 @@ +package apptive.devlog.domain; + + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +@Getter +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/domain/Comment.java b/src/main/java/apptive/devlog/domain/Comment.java new file mode 100644 index 0000000..ec83b97 --- /dev/null +++ b/src/main/java/apptive/devlog/domain/Comment.java @@ -0,0 +1,53 @@ +package apptive.devlog.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends BaseTimeEntity { + + @Id + @GeneratedValue + @Column(name = "comment_id") + private Long id; + + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @OneToMany(mappedBy = "parent") // 고아 객체 생성 가능성 + private List comments = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + private boolean isDeleted = false; + + public Comment(String content, Member member, Post post, Comment parent) { + this.content = content; + this.member = member; + this.post = post; + this.parent = parent; + } + + public void softDelete() { + isDeleted = true; + } + + public void changeContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/apptive/devlog/domain/Gender.java b/src/main/java/apptive/devlog/domain/Gender.java new file mode 100644 index 0000000..580e1a7 --- /dev/null +++ b/src/main/java/apptive/devlog/domain/Gender.java @@ -0,0 +1,5 @@ +package apptive.devlog.domain; + +public enum Gender { + MALE, FEMALE; +} diff --git a/src/main/java/apptive/devlog/domain/Member.java b/src/main/java/apptive/devlog/domain/Member.java new file mode 100644 index 0000000..345d688 --- /dev/null +++ b/src/main/java/apptive/devlog/domain/Member.java @@ -0,0 +1,76 @@ +package apptive.devlog.domain; + + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Member extends BaseTimeEntity { + + @Id @GeneratedValue + @Column(name = "member_id") + private Long id; + + private String email; + + private String password; + + private String username; + + private String nickname; + + private LocalDate birthdate; + + private String role; + + @Enumerated(EnumType.STRING) + private Gender gender; //enum 값 검증 필요 + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private List posts = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private List uploadFiles = new ArrayList<>(); + + + public Member(String email, String password, String username, String nickname, LocalDate birthdate, String role, Gender gender) { + this.email = email; + this.password = password; + this.username = username; + this.nickname = nickname; + this.birthdate = birthdate; + this.role = role; + this.gender = gender; + } + + public Member(String email, String role) { + this.email = email; + this.role = role; + } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updateBirthdate(LocalDate birthdate) { + this.birthdate = birthdate; + } + + public void updateGender(Gender gender) { + this.gender = gender; + } + + public void updatePassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/apptive/devlog/domain/Post.java b/src/main/java/apptive/devlog/domain/Post.java new file mode 100644 index 0000000..9806988 --- /dev/null +++ b/src/main/java/apptive/devlog/domain/Post.java @@ -0,0 +1,49 @@ +package apptive.devlog.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseTimeEntity { + + @Id + @GeneratedValue + @Column(name = "post_id") + private Long id; + + private String title; + + @Lob // 255개 이상의 문자를 저장하고 싶을 때 사용한다. + @Column(length = 65535) + private String content; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List comments; + + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private List files = new ArrayList<>(); + + public Post(String title, String content, Member member) { + this.title = title; + this.content = content; + this.member = member; + } + + public void changeTitle(String title) { + this.title = title; + } + + public void changeContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/apptive/devlog/domain/RefreshEntity.java b/src/main/java/apptive/devlog/domain/RefreshEntity.java new file mode 100644 index 0000000..d8020bd --- /dev/null +++ b/src/main/java/apptive/devlog/domain/RefreshEntity.java @@ -0,0 +1,30 @@ +package apptive.devlog.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +public class RefreshEntity { + + @Id @GeneratedValue + Long id; + + private String email; + + private String refresh; + + private String expiration; + + protected RefreshEntity() {} + + public RefreshEntity(String email, String refresh, String expiration) { + this.email = email; + this.refresh = refresh; + this.expiration = expiration; + } +} diff --git a/src/main/java/apptive/devlog/domain/UploadFile.java b/src/main/java/apptive/devlog/domain/UploadFile.java new file mode 100644 index 0000000..73bf6a2 --- /dev/null +++ b/src/main/java/apptive/devlog/domain/UploadFile.java @@ -0,0 +1,48 @@ +package apptive.devlog.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class UploadFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "file_id") + private Long id; + + @Column(nullable = false) + private String fileName; + + @Column(nullable = false) + private String serverFileName; + + @Column(nullable = false, length = 65535) + @Lob + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id") + private Post post; + + public UploadFile(String fileName, String serverFileName, String url, Post post, Member member) { + this.fileName = fileName; + this.serverFileName = serverFileName; + this.url = url; + this.post = post; + this.member = member; + } + + + +} diff --git a/src/main/java/apptive/devlog/error/ErrorMessage.java b/src/main/java/apptive/devlog/error/ErrorMessage.java new file mode 100644 index 0000000..1e5578a --- /dev/null +++ b/src/main/java/apptive/devlog/error/ErrorMessage.java @@ -0,0 +1,19 @@ +package apptive.devlog.error; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Getter //@Getter가 없으면 HttpMediaTypeNotAcceptableException터짐 -> Jackson이 Json으로 변환할때 get이 필요 +public class ErrorMessage { + + public Map messages; + + + + public ErrorMessage(Map messages) { + this.messages = messages; + } +} diff --git a/src/main/java/apptive/devlog/fileupload/controller/UploadController.java b/src/main/java/apptive/devlog/fileupload/controller/UploadController.java new file mode 100644 index 0000000..2ae1aca --- /dev/null +++ b/src/main/java/apptive/devlog/fileupload/controller/UploadController.java @@ -0,0 +1,38 @@ +package apptive.devlog.fileupload.controller; + +import apptive.devlog.fileupload.dto.UploadFileDto; +import apptive.devlog.fileupload.service.UploadService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URL; + +@RestController +@RequiredArgsConstructor +public class UploadController { + + private final UploadService uploadService; + + + // Multipart 기반 + @PostMapping("/file/v1/upload") + public ResponseEntity uploadV1(@RequestParam MultipartFile file) { + UploadFileDto response = uploadService.upload(file); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + // Pre-Signed 기반 + @PostMapping("/file/v2/upload") + public ResponseEntity uploadV2(@RequestParam String fileName) { + + UploadFileDto response = uploadService.getPresignedURL(fileName); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/src/main/java/apptive/devlog/fileupload/dto/UploadFileDto.java b/src/main/java/apptive/devlog/fileupload/dto/UploadFileDto.java new file mode 100644 index 0000000..2e8e674 --- /dev/null +++ b/src/main/java/apptive/devlog/fileupload/dto/UploadFileDto.java @@ -0,0 +1,18 @@ +package apptive.devlog.fileupload.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UploadFileDto { + + private String fileName; + + private String serverFileName; + + private String url; +} diff --git a/src/main/java/apptive/devlog/fileupload/exception/FileUploadException.java b/src/main/java/apptive/devlog/fileupload/exception/FileUploadException.java new file mode 100644 index 0000000..70d4be3 --- /dev/null +++ b/src/main/java/apptive/devlog/fileupload/exception/FileUploadException.java @@ -0,0 +1,7 @@ +package apptive.devlog.fileupload.exception; + +public class FileUploadException extends RuntimeException { + public FileUploadException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/fileupload/repository/UploadRepository.java b/src/main/java/apptive/devlog/fileupload/repository/UploadRepository.java new file mode 100644 index 0000000..f8267c8 --- /dev/null +++ b/src/main/java/apptive/devlog/fileupload/repository/UploadRepository.java @@ -0,0 +1,8 @@ +package apptive.devlog.fileupload.repository; + +import apptive.devlog.domain.UploadFile; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UploadRepository extends JpaRepository { + +} diff --git a/src/main/java/apptive/devlog/fileupload/service/UploadService.java b/src/main/java/apptive/devlog/fileupload/service/UploadService.java new file mode 100644 index 0000000..280d537 --- /dev/null +++ b/src/main/java/apptive/devlog/fileupload/service/UploadService.java @@ -0,0 +1,80 @@ +package apptive.devlog.fileupload.service; + +import apptive.devlog.domain.UploadFile; +import apptive.devlog.fileupload.dto.UploadFileDto; +import apptive.devlog.fileupload.exception.FileUploadException; +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ListObjectsV2Result; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3ObjectSummary; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +@Service +@Transactional +@RequiredArgsConstructor +public class UploadService { + + private final AmazonS3Client s3Client; + + @Value("${s3.bucket}") + private String bucket; + + + public UploadFileDto upload(MultipartFile file) { + + String fileName = file.getName(); + String serverFileName = changeFileName(fileName); + + ObjectMetadata metaData = new ObjectMetadata(); + metaData.setContentType(file.getContentType()); + metaData.setContentLength(file.getSize()); + try { + s3Client.putObject(bucket, serverFileName, file.getInputStream(), metaData); + } catch (IOException e) { + throw new FileUploadException("파일 업로드 실패"); + } + String url = s3Client.getUrl(bucket, serverFileName).toString(); + + return new UploadFileDto(fileName, serverFileName, url); + } + + public UploadFileDto getPresignedURL(String fileName) { + + String serverFileName = changeFileName(fileName); + Date expiration = new Date(); + long expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 5; // 5분 + expiration.setTime(expTimeMillis); + String url = s3Client.generatePresignedUrl(bucket, serverFileName, expiration, HttpMethod.PUT).toString(); + + + return new UploadFileDto(fileName, serverFileName, url); + } + + public void deleteFiles(List files) { + for (UploadFile file : files) { + s3Client.deleteObject(bucket, file.getServerFileName()); + } + } + + + private String changeFileName(String originalFileName) { + String ext = originalFileName.substring(originalFileName.lastIndexOf(".")); + String uuid = UUID.randomUUID().toString(); + return uuid + ext; + } + +} diff --git a/src/main/java/apptive/devlog/home/HomeController.java b/src/main/java/apptive/devlog/home/HomeController.java new file mode 100644 index 0000000..469371c --- /dev/null +++ b/src/main/java/apptive/devlog/home/HomeController.java @@ -0,0 +1,20 @@ +package apptive.devlog.home; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HomeController { + + @GetMapping("/home") + public String home() { + return "home"; + } + + @GetMapping("/") + public String hello() { + return "hello"; + } + +} diff --git a/src/main/java/apptive/devlog/member/controller/MemberController.java b/src/main/java/apptive/devlog/member/controller/MemberController.java new file mode 100644 index 0000000..3a9c1f4 --- /dev/null +++ b/src/main/java/apptive/devlog/member/controller/MemberController.java @@ -0,0 +1,45 @@ +package apptive.devlog.member.controller; + +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.dto.MemberDetails; +import apptive.devlog.member.dto.MemberUpdateForm; +import apptive.devlog.member.service.MemberService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/users") + public ResponseEntity> signUp(@Valid @RequestBody JoinForm form) { + memberService.join(form); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of("message", "회원가입 성공")); + } + + @DeleteMapping("/users/me") + public ResponseEntity> withdraw(@AuthenticationPrincipal MemberDetails memberDetails) { + + memberService.withdraw(memberDetails.getUsername()); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(Map.of("message", "회원탈퇴 성공")); + } + + @PatchMapping("/users/me") + public ResponseEntity> updateMember(@RequestBody MemberUpdateForm form, + @AuthenticationPrincipal MemberDetails memberDetails) { + + memberService.update(memberDetails.getUsername(), form); + + return ResponseEntity.status(HttpStatus.OK).body(Map.of("message", "회원정보 변경 성공")); + } + +} diff --git a/src/main/java/apptive/devlog/member/controller/ReissueController.java b/src/main/java/apptive/devlog/member/controller/ReissueController.java new file mode 100644 index 0000000..858e752 --- /dev/null +++ b/src/main/java/apptive/devlog/member/controller/ReissueController.java @@ -0,0 +1,58 @@ +package apptive.devlog.member.controller; + +import apptive.devlog.domain.RefreshEntity; +import apptive.devlog.member.jwt.JWTUtil; +import apptive.devlog.member.repository.RefreshRepository; +import apptive.devlog.member.service.RefreshService; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.Date; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class ReissueController { + + private final RefreshService refreshService; + + @PostMapping("/reissue") + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response) { + + if (request.getCookies() == null) return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message", "리프래시 토큰이 존재하지 않습니다")); + + Cookie[] cookies = request.getCookies(); + + String refresh = Arrays.stream(cookies) + .filter(cookie -> cookie.getName().equals("refresh")) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + + String[] tokens = refreshService.validateRefreshToken(refresh); + + response.setHeader("access", tokens[0]); + response.addCookie(createCookie("refresh",tokens[1])); + + return new ResponseEntity<>(HttpStatus.OK); + + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24*60*60); + cookie.setHttpOnly(true); + return cookie; + } +} + diff --git a/src/main/java/apptive/devlog/member/dto/JoinForm.java b/src/main/java/apptive/devlog/member/dto/JoinForm.java new file mode 100644 index 0000000..00b59c5 --- /dev/null +++ b/src/main/java/apptive/devlog/member/dto/JoinForm.java @@ -0,0 +1,57 @@ +package apptive.devlog.member.dto; + +import apptive.devlog.domain.Gender; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.*; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class JoinForm { + + @Email(message = "이메일 형식에 맞게 입력해주세요") //이메일 형식 검증 + @NotBlank(message = "이메일을 입력해주세요") // 만약 null이나 빈 문자열이 넘어오면 검증 자체를 못한다. + private String email; + + @Size(min = 10, max = 20, message = "비밀번호는 10자이상 20자이하로 입력해주세요") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~`!@#$%^&*()_+=\\-\\[\\]{}|\\\\:;\"'<>,.?/]).{10,20}$", + message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다." + ) + private String password; + + @Size(min = 10, max = 20, message = "비밀번호는 10자이상 20자이하로 입력해주세요") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~`!@#$%^&*()_+=\\-\\[\\]{}|\\\\:;\"'<>,.?/]).{10,20}$", + message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다." + ) + private String confirmPassword; + + @NotBlank(message = "이름을 입력해주세요") + private String username; + + @NotBlank(message = "별명을 입력해주세요") + private String nickname; + + @Past(message = "생년월일은 과거 날짜여야 합니다.") + @NotNull(message = "생년월일을 입력해주세요") + private LocalDate birthdate; + + @Enumerated(EnumType.STRING) + @NotNull(message = "성별을 입력해주세요") + private Gender gender; + + public JoinForm(String email, String password, String confirmPassword, String username, String nickname, LocalDate birthdate, Gender gender) { + this.email = email; + this.password = password; + this.confirmPassword = confirmPassword; + this.username = username; + this.nickname = nickname; + this.birthdate = birthdate; + this.gender = gender; + } + + protected JoinForm() {} +} diff --git a/src/main/java/apptive/devlog/member/dto/LoginForm.java b/src/main/java/apptive/devlog/member/dto/LoginForm.java new file mode 100644 index 0000000..6255cb1 --- /dev/null +++ b/src/main/java/apptive/devlog/member/dto/LoginForm.java @@ -0,0 +1,21 @@ +package apptive.devlog.member.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class LoginForm { + + @NotNull(message = "아이디(이메일)를 입력해주세요") + private String email; + + @NotNull(message = "비밀번호를 입력해주세요") + private String password; + + public LoginForm(String email, String password) { + this.email = email; + this.password = password; + } + + public LoginForm() {} +} diff --git a/src/main/java/apptive/devlog/member/dto/MemberDetails.java b/src/main/java/apptive/devlog/member/dto/MemberDetails.java new file mode 100644 index 0000000..7664a22 --- /dev/null +++ b/src/main/java/apptive/devlog/member/dto/MemberDetails.java @@ -0,0 +1,63 @@ +package apptive.devlog.member.dto; + +import apptive.devlog.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +@RequiredArgsConstructor +public class MemberDetails implements UserDetails { + + private final Member member; + + public Member getMember() { + return member; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return member.getRole(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/apptive/devlog/member/dto/MemberUpdateForm.java b/src/main/java/apptive/devlog/member/dto/MemberUpdateForm.java new file mode 100644 index 0000000..6da6165 --- /dev/null +++ b/src/main/java/apptive/devlog/member/dto/MemberUpdateForm.java @@ -0,0 +1,62 @@ +package apptive.devlog.member.dto; + + +import apptive.devlog.domain.Gender; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; + +@Getter +@Setter +public class MemberUpdateForm { + + + @NotBlank(message = "별명을 입력해주세요") + private String nickname; + + @Size(min = 10, max = 20, message = "비밀번호는 10자이상 20자이하로 입력해주세요") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~`!@#$%^&*()_+=\\-\\[\\]{}|\\\\:;\"'<>,.?/]).{10,20}$", + message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다." + ) + private String currentPassword; + + @Size(min = 10, max = 20, message = "비밀번호는 10자이상 20자이하로 입력해주세요") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~`!@#$%^&*()_+=\\-\\[\\]{}|\\\\:;\"'<>,.?/]).{10,20}$", + message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다." + ) + private String newPassword; + + @Size(min = 10, max = 20, message = "비밀번호는 10자이상 20자이하로 입력해주세요") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~`!@#$%^&*()_+=\\-\\[\\]{}|\\\\:;\"'<>,.?/]).{10,20}$", + message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 모두 포함해야 합니다." + ) + private String confirmPassword; + + @Past(message = "생년월일은 과거 날짜여야 합니다.") + @NotNull(message = "생년월일을 입력해주세요") + private LocalDate birthdate; + + @Enumerated(EnumType.STRING) + @NotNull(message = "성별을 입력해주세요") + private Gender gender; + + + public MemberUpdateForm(String nickname, String currentPassword, String newPassword, String confirmPassword, LocalDate birthdate, Gender gender) { + this.nickname = nickname; + this.currentPassword = currentPassword; + this.newPassword = newPassword; + this.confirmPassword = confirmPassword; + this.birthdate = birthdate; + this.gender = gender; + } + + protected MemberUpdateForm() {} + +} diff --git a/src/main/java/apptive/devlog/member/exception/DuplicateMemberException.java b/src/main/java/apptive/devlog/member/exception/DuplicateMemberException.java new file mode 100644 index 0000000..8e8e4cf --- /dev/null +++ b/src/main/java/apptive/devlog/member/exception/DuplicateMemberException.java @@ -0,0 +1,13 @@ +package apptive.devlog.member.exception; + +import apptive.devlog.error.ErrorMessage; +import lombok.Getter; + +@Getter +public class DuplicateMemberException extends RuntimeException { + + private ErrorMessage errorMessage; + public DuplicateMemberException(ErrorMessage errorMessage) { + this.errorMessage = errorMessage; + } +} diff --git a/src/main/java/apptive/devlog/member/exception/MemberExceptionHandler.java b/src/main/java/apptive/devlog/member/exception/MemberExceptionHandler.java new file mode 100644 index 0000000..95c2feb --- /dev/null +++ b/src/main/java/apptive/devlog/member/exception/MemberExceptionHandler.java @@ -0,0 +1,52 @@ +package apptive.devlog.member.exception; + +import apptive.devlog.error.ErrorMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class MemberExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity beanValidationHandler(MethodArgumentNotValidException ex) { + + Map messages = ex.getBindingResult().getFieldErrors() + .stream() + .collect(Collectors.toMap(e-> e.getField(), e-> e.getDefaultMessage(), + (existing, replacement) -> existing)); + + ErrorMessage errorMessage = new ErrorMessage(messages); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessage); + } + + @ExceptionHandler(DuplicateMemberException.class) + public ResponseEntity duplicateMemberHandler(DuplicateMemberException ex) { + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getErrorMessage()); + } + + @ExceptionHandler(NotFoundMemberException.class) + public ResponseEntity> notFoundMemberHandler(NotFoundMemberException ex) { + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } + + @ExceptionHandler(PasswordException.class) + public ResponseEntity> passwordHandler(PasswordException ex) { + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message",ex.getMessage())); + } + + @ExceptionHandler(RefreshTokenValidateException.class) + public ResponseEntity> refreshTokenValidateHandler(RefreshTokenValidateException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message",ex.getMessage())); + } + +} diff --git a/src/main/java/apptive/devlog/member/exception/NotFoundMemberException.java b/src/main/java/apptive/devlog/member/exception/NotFoundMemberException.java new file mode 100644 index 0000000..4ef2f6c --- /dev/null +++ b/src/main/java/apptive/devlog/member/exception/NotFoundMemberException.java @@ -0,0 +1,8 @@ +package apptive.devlog.member.exception; + +public class NotFoundMemberException extends RuntimeException { + + public NotFoundMemberException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/member/exception/PasswordException.java b/src/main/java/apptive/devlog/member/exception/PasswordException.java new file mode 100644 index 0000000..40e2c3d --- /dev/null +++ b/src/main/java/apptive/devlog/member/exception/PasswordException.java @@ -0,0 +1,7 @@ +package apptive.devlog.member.exception; + +public class PasswordException extends RuntimeException { + public PasswordException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/member/exception/RefreshTokenValidateException.java b/src/main/java/apptive/devlog/member/exception/RefreshTokenValidateException.java new file mode 100644 index 0000000..4ee1660 --- /dev/null +++ b/src/main/java/apptive/devlog/member/exception/RefreshTokenValidateException.java @@ -0,0 +1,7 @@ +package apptive.devlog.member.exception; + +public class RefreshTokenValidateException extends RuntimeException { + public RefreshTokenValidateException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/member/jwt/CustomLogoutFilter.java b/src/main/java/apptive/devlog/member/jwt/CustomLogoutFilter.java new file mode 100644 index 0000000..b1d008c --- /dev/null +++ b/src/main/java/apptive/devlog/member/jwt/CustomLogoutFilter.java @@ -0,0 +1,88 @@ +package apptive.devlog.member.jwt; + +import apptive.devlog.member.repository.RefreshRepository; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +@RequiredArgsConstructor +public class CustomLogoutFilter extends GenericFilterBean { + + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + doFilter((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse, filterChain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + + String requestURI = request.getRequestURI(); + + if (!requestURI.matches("^\\/logout$")) { // \\/ -> \로 인식, $-> 문자열의 끝, logouts는 매칭되지 않는다. + filterChain.doFilter(request,response); + return; + } + + if(!request.getMethod().equals("POST")) { + filterChain.doFilter(request,response); + return; + } + //위의 로직은 POST /logout 요청인지 확인하는 로직 해당 요청이 아니라면 다음 필터로 진행 + + + String refresh = null; + + Cookie[] cookies = request.getCookies(); + + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refresh")) { + refresh = cookie.getValue(); + } + } + + if (refresh == null) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + try { + jwtUtil.isExpired(refresh); + } + catch (ExpiredJwtException e) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + String category = jwtUtil.getCategory(refresh); + + if(!category.equals("refresh")) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + if(!refreshRepository.existsByRefresh(refresh)) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + refreshRepository.deleteByRefresh(refresh); + + Cookie cookie = new Cookie("refresh", null); + cookie.setMaxAge(0); + cookie.setPath("/"); //모든 url 경로에서 쿠키를 삭제 + + response.addCookie(cookie); + response.setStatus(HttpServletResponse.SC_OK); + } +} diff --git a/src/main/java/apptive/devlog/member/jwt/JWTFilter.java b/src/main/java/apptive/devlog/member/jwt/JWTFilter.java new file mode 100644 index 0000000..1a8d67e --- /dev/null +++ b/src/main/java/apptive/devlog/member/jwt/JWTFilter.java @@ -0,0 +1,70 @@ +package apptive.devlog.member.jwt; + +import apptive.devlog.domain.Member; +import apptive.devlog.member.dto.MemberDetails; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String accessToken = request.getHeader("access"); + + String requestURI = request.getRequestURI(); + + if (accessToken == null || requestURI.equals("/login") || requestURI.equals("/reissue") + || requestURI.equals("/connect/**") || requestURI.equals("/users")) { + filterChain.doFilter(request,response); + return; + } + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + response.getWriter().write("access token expired"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String category = jwtUtil.getCategory(accessToken); + + if (!category.equals("access")) { + response.getWriter().write("invalid access token"); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String email = jwtUtil.getUsername(accessToken); + String role = jwtUtil.getRole(accessToken); + + log.info("email: {}, role: {}", email, role); + + Member member = new Member(email, role); //로그인이 되었기 때문에 비밀번호 필요없음 + + MemberDetails memberDetails = new MemberDetails(member); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken + (memberDetails, null, memberDetails.getAuthorities()); + + log.info("is Authenticated : JWTFilter = {}", authToken.isAuthenticated()); + + SecurityContextHolder.getContext().setAuthentication(authToken); //다음 필터가 인증정보를 기억하기 위해 + + filterChain.doFilter(request,response); + } +} diff --git a/src/main/java/apptive/devlog/member/jwt/JWTUtil.java b/src/main/java/apptive/devlog/member/jwt/JWTUtil.java new file mode 100644 index 0000000..7c4695d --- /dev/null +++ b/src/main/java/apptive/devlog/member/jwt/JWTUtil.java @@ -0,0 +1,53 @@ +package apptive.devlog.member.jwt; + +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JWTUtil { + + private SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token) + .getPayload().get("username", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token) + .getPayload().get("role", String.class); + } + + public String getCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token) + .getPayload().get("category", String.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token) + .getPayload().getExpiration().before(new Date()); + } + + public String createJWT(String category, String username, String role, Long expiredMs) { + + return Jwts.builder() + .claim("category", category) + .claim("username", username) + .claim("role",role) + .issuedAt(new Date(System.currentTimeMillis())) //발행시간 + .expiration(new Date(System.currentTimeMillis()+expiredMs)) //소멸시간 + .signWith(secretKey) //암호화 진행 + .compact(); //토큰 생성 + } +} diff --git a/src/main/java/apptive/devlog/member/jwt/LoginFilter.java b/src/main/java/apptive/devlog/member/jwt/LoginFilter.java new file mode 100644 index 0000000..dc2cc31 --- /dev/null +++ b/src/main/java/apptive/devlog/member/jwt/LoginFilter.java @@ -0,0 +1,108 @@ +package apptive.devlog.member.jwt; + +import apptive.devlog.domain.RefreshEntity; +import apptive.devlog.member.dto.LoginForm; +import apptive.devlog.member.dto.MemberDetails; +import apptive.devlog.member.repository.RefreshRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import java.util.Map; + +import static java.nio.charset.StandardCharsets.*; + +@Slf4j +@RequiredArgsConstructor +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + @Override //필수 오버라이드 + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + LoginForm loginForm = new LoginForm(); + //@RequestBody기능을 사용할 수 없기 때문에 직접 문자열을 Json으로 파싱해줘야함 + try { + ObjectMapper objectMapper = new ObjectMapper(); + ServletInputStream inputStream = request.getInputStream(); + String messageBody = StreamUtils.copyToString(inputStream, UTF_8); + loginForm = objectMapper.readValue(messageBody, LoginForm.class); + } + catch (IOException e) { + throw new RuntimeException(e); + } + + String email = loginForm.getEmail(); + String password = loginForm.getPassword(); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(email, password); //로그인 되지 않은 상태에서는 아이디와 비밀번호로 인증 + + log.info("is Authenticated : LoginFilter = {}", authToken.isAuthenticated()); + return authenticationManager.authenticate(authToken); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException { + + String email = authentication.getName(); // email + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); // role + + String access = jwtUtil.createJWT("access", email, role, 600000L); + String refresh = jwtUtil.createJWT("refresh", email, role, 86400000L); + + addRefreshEntity(email, refresh, 86400000L); + + response.setHeader("access",access); + response.addCookie(createCookie("refresh",refresh)); + response.setStatus(HttpServletResponse.SC_OK); + + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + + private void addRefreshEntity(String email, String refresh, Long expiredMs) { + Date date = new Date(System.currentTimeMillis() + expiredMs); + + RefreshEntity refreshEntity = new RefreshEntity(email, refresh, date.toString()); + + refreshRepository.save(refreshEntity); + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24*60*60); + cookie.setHttpOnly(true); //자바 스크립트로 쿠키 접근 금지 + return cookie; + } +} diff --git a/src/main/java/apptive/devlog/member/repository/MemberRepository.java b/src/main/java/apptive/devlog/member/repository/MemberRepository.java new file mode 100644 index 0000000..033c7f6 --- /dev/null +++ b/src/main/java/apptive/devlog/member/repository/MemberRepository.java @@ -0,0 +1,18 @@ +package apptive.devlog.member.repository; + +import apptive.devlog.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByEmail(String email); //timeout, 인덱스 고려 + Optional findByNickname(String nickname); //timeout, 인덱스 고려 + + + @Query("select m from Member m left join fetch m.uploadFiles where m.email = :email") + Optional findByEmailWithFiles(String email); +} diff --git a/src/main/java/apptive/devlog/member/repository/RefreshRepository.java b/src/main/java/apptive/devlog/member/repository/RefreshRepository.java new file mode 100644 index 0000000..b9a60e2 --- /dev/null +++ b/src/main/java/apptive/devlog/member/repository/RefreshRepository.java @@ -0,0 +1,18 @@ +package apptive.devlog.member.repository; + +import apptive.devlog.domain.RefreshEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface RefreshRepository extends JpaRepository { + + Boolean existsByEmail(String email); + + Boolean existsByRefresh(String refresh); + + @Transactional + void deleteByRefresh(String refresh); + + @Transactional + void deleteByEmail(String email); +} diff --git a/src/main/java/apptive/devlog/member/service/MemberService.java b/src/main/java/apptive/devlog/member/service/MemberService.java new file mode 100644 index 0000000..c6550aa --- /dev/null +++ b/src/main/java/apptive/devlog/member/service/MemberService.java @@ -0,0 +1,99 @@ +package apptive.devlog.member.service; + +import apptive.devlog.domain.Member; +import apptive.devlog.domain.UploadFile; +import apptive.devlog.error.ErrorMessage; +import apptive.devlog.fileupload.service.UploadService; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.dto.MemberDetails; +import apptive.devlog.member.dto.MemberUpdateForm; +import apptive.devlog.member.exception.DuplicateMemberException; +import apptive.devlog.member.exception.NotFoundMemberException; +import apptive.devlog.member.exception.PasswordException; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.member.repository.RefreshRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; +import java.util.Map; + + +@Transactional +@Service +@RequiredArgsConstructor +public class MemberService implements UserDetailsService { + + private final MemberRepository memberRepository; + private final RefreshRepository refreshRepository; + private final BCryptPasswordEncoder passwordEncoder; + private final UploadService uploadService; + + @Override + public UserDetails loadUserByUsername(String email) { + Member findMember = memberRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 이메일")); + + return new MemberDetails(findMember); + } + + public void join(JoinForm form) { + String password = passwordEncoder.encode(form.getPassword()); + + HashMap errors = new HashMap<>(); + + if (memberRepository.findByEmail(form.getEmail()).isPresent()) + errors.put("email", "이미 사용중인 이메일입니다"); + if (memberRepository.findByNickname(form.getNickname()).isPresent()) + errors.put("nickname", "이미 사용중인 별명입니다"); + + if (!errors.isEmpty()) throw new DuplicateMemberException(new ErrorMessage(errors)); + + if (!form.getPassword().equals(form.getConfirmPassword())) throw new PasswordException("비밀번호가 서로 다릅니다."); + + Member member = new Member(form.getEmail(), password, form.getUsername(), form.getNickname(), + form.getBirthdate(), "ROLE_MEMBER", form.getGender()); + + memberRepository.save(member); + } + + public void update(String email, MemberUpdateForm form) { + Member findMember = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundMemberException("존재하는 회원이 없습니다")); + + + Map errors = new HashMap<>(); + + if (!findMember.getNickname().equals(form.getNickname()) && memberRepository.findByNickname(form.getNickname()).isPresent()) + errors.put("nickname", "이미 사용중인 별명입니다"); + if (!errors.isEmpty()) throw new DuplicateMemberException(new ErrorMessage(errors)); + + + if (!passwordEncoder.matches(form.getCurrentPassword(), findMember.getPassword())) throw new PasswordException("현재 비밀번호와 다릅니다."); + if (!form.getNewPassword().equals(form.getConfirmPassword())) throw new PasswordException("새로운 비밀번호가 서로 다릅니다."); + + String newPassword = passwordEncoder.encode(form.getNewPassword()); + + findMember.updateNickname(form.getNickname()); + findMember.updatePassword(newPassword); + findMember.updateBirthdate(form.getBirthdate()); + findMember.updateGender(form.getGender()); + } + + public void withdraw(String email) { + + Member findMember = memberRepository.findByEmailWithFiles(email) + .orElseThrow(() -> new NotFoundMemberException("존재하는 회원이 없습니다")); + + uploadService.deleteFiles(findMember.getUploadFiles()); + + memberRepository.delete(findMember); + + if (refreshRepository.existsByEmail(email)) refreshRepository.deleteByEmail(email); + + } +} diff --git a/src/main/java/apptive/devlog/member/service/RefreshService.java b/src/main/java/apptive/devlog/member/service/RefreshService.java new file mode 100644 index 0000000..51d29e5 --- /dev/null +++ b/src/main/java/apptive/devlog/member/service/RefreshService.java @@ -0,0 +1,66 @@ +package apptive.devlog.member.service; + +import apptive.devlog.domain.RefreshEntity; +import apptive.devlog.member.exception.RefreshTokenValidateException; +import apptive.devlog.member.jwt.JWTUtil; +import apptive.devlog.member.repository.RefreshRepository; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +@Transactional +@RequiredArgsConstructor +@Service +public class RefreshService { + + private final RefreshRepository refreshRepository; + private final JWTUtil jwtUtil; + + public String[] validateRefreshToken(String refresh) { + if (refresh == null) { + throw new RefreshTokenValidateException("리프래시 토큰이 존재하지 않습니다"); + } + + try { + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e) { + throw new RefreshTokenValidateException("리프래시 토큰이 만료되었습니다."); + } + + String category = jwtUtil.getCategory(refresh); + + + if (!category.equals("refresh")) { + throw new RefreshTokenValidateException("리프래시 토큰이 아닙니다."); + } + + if(!refreshRepository.existsByRefresh(refresh)) { + throw new RefreshTokenValidateException("잘못된 리프래시 토큰입니다."); + } + + String email = jwtUtil.getUsername(refresh); + String role = jwtUtil.getRole(refresh); + + String access = jwtUtil.createJWT("access", email, role, 600000L); + String newRefresh = jwtUtil.createJWT("refresh", email, role, 86400000L); + + refreshRepository.deleteByEmail(email); + addRefreshEntity(email, newRefresh, 86400000L); + + return new String[]{access, newRefresh}; + } + + private void addRefreshEntity(String email, String refresh, Long expiredMs) { + Date date = new Date(System.currentTimeMillis() + expiredMs); + + RefreshEntity refreshEntity = new RefreshEntity(email, refresh, date.toString()); + + refreshRepository.save(refreshEntity); + } +} diff --git a/src/main/java/apptive/devlog/post/controller/PostController.java b/src/main/java/apptive/devlog/post/controller/PostController.java new file mode 100644 index 0000000..ec5845d --- /dev/null +++ b/src/main/java/apptive/devlog/post/controller/PostController.java @@ -0,0 +1,78 @@ +package apptive.devlog.post.controller; + +import apptive.devlog.member.dto.MemberDetails; +import apptive.devlog.member.service.MemberService; +import apptive.devlog.post.dto.*; +import apptive.devlog.post.service.PostService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.apache.coyote.Response; +import org.springframework.data.domain.Pageable; +import org.springframework.data.repository.query.Param; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class PostController { + + private final PostService postService; + + @PostMapping("/me/post") + public ResponseEntity createPost(@Valid @RequestBody CreatePostRequest post, + @AuthenticationPrincipal MemberDetails memberDetails) { + PostResponse response = postService.save(post, memberDetails.getMember().getEmail()); + + URI location = ServletUriComponentsBuilder + .fromCurrentRequestUri() + .replacePath("/users/{nickname}/post/{id}") + .buildAndExpand(response.getAuthor(), response.getId()) + .toUri(); + + return ResponseEntity.status(HttpStatus.CREATED).location(location).body(response); + } + + @PatchMapping("/me/post/{id}") + public ResponseEntity> updatePost(@Valid @RequestBody UpdatePostRequest post, + @PathVariable Long id, + @AuthenticationPrincipal MemberDetails member) { + postService.updatePost(id,member.getUsername(), post); + return ResponseEntity.status(HttpStatus.OK).body(Map.of("message","게시글 수정 성공")); + } + + @DeleteMapping("/me/post/{id}") + public ResponseEntity> deletePost(@AuthenticationPrincipal MemberDetails member, + @PathVariable Long id) { + postService.deletePost(id,member.getUsername()); + + return ResponseEntity.status(HttpStatus.NO_CONTENT).body(Map.of("message", "게시글 삭제 완료")); + } + + + @GetMapping("/post/{id}") + public ResponseEntity getPost(@PathVariable Long id, @PageableDefault(page=0, size = 20) Pageable pageable) { + PostWithCommentResponse post = postService.findPost(id, pageable); + return ResponseEntity.status(HttpStatus.OK).body(post); + } + + + @GetMapping("/{nickname}/post") + public ResponseEntity memberPosts(@PathVariable String nickname, + @RequestParam(required = false) String title, + @PageableDefault(page=0, size = 10) Pageable pageable) { + PostPageResponse response = postService.findPostsByMember(nickname, pageable, title); + + return ResponseEntity.status(HttpStatus.OK).body(response); + } + + +} diff --git a/src/main/java/apptive/devlog/post/dto/CreatePostRequest.java b/src/main/java/apptive/devlog/post/dto/CreatePostRequest.java new file mode 100644 index 0000000..341a3df --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/CreatePostRequest.java @@ -0,0 +1,30 @@ +package apptive.devlog.post.dto; + +import apptive.devlog.fileupload.dto.UploadFileDto; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CreatePostRequest { + + @NotBlank + String title; + + @NotBlank + String content; + + List files = new ArrayList<>(); + + public CreatePostRequest(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/apptive/devlog/post/dto/PostPageResponse.java b/src/main/java/apptive/devlog/post/dto/PostPageResponse.java new file mode 100644 index 0000000..24a00ea --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostPageResponse.java @@ -0,0 +1,19 @@ +package apptive.devlog.post.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostPageResponse { + + List posts; + int totalPages; + + +} diff --git a/src/main/java/apptive/devlog/post/dto/PostResponse.java b/src/main/java/apptive/devlog/post/dto/PostResponse.java new file mode 100644 index 0000000..9831bd5 --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostResponse.java @@ -0,0 +1,33 @@ +package apptive.devlog.post.dto; + +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.domain.Post; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PostResponse { + private Long id; + private String author; + + private String title; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public PostResponse(Post post, String author) { + this.id = post.getId(); + this.author = author; + this.title = post.getTitle(); + this.content = post.getContent(); + this.createdAt = post.getCreatedAt(); + this.updatedAt = post.getUpdatedAt(); + } +} diff --git a/src/main/java/apptive/devlog/post/dto/PostSearchRequest.java b/src/main/java/apptive/devlog/post/dto/PostSearchRequest.java new file mode 100644 index 0000000..1f195f4 --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostSearchRequest.java @@ -0,0 +1,14 @@ +package apptive.devlog.post.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PostSearchRequest { + + private String title; +} diff --git a/src/main/java/apptive/devlog/post/dto/PostWithCommentResponse.java b/src/main/java/apptive/devlog/post/dto/PostWithCommentResponse.java new file mode 100644 index 0000000..5be9c6b --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostWithCommentResponse.java @@ -0,0 +1,39 @@ +package apptive.devlog.post.dto; + +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.domain.Post; +import apptive.devlog.fileupload.dto.UploadFileDto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PostWithCommentResponse { + + private Long id; + private String author; + + private String title; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List comments; + private int totalPages; + + public PostWithCommentResponse(Post post, List comments, int totalPages) { + this.id = post.getId(); + this.author = post.getMember().getNickname(); + this.title = post.getTitle(); + this.content = post.getContent(); + this.createdAt = post.getCreatedAt(); + this.updatedAt = post.getUpdatedAt(); + this.comments = comments; + this.totalPages = totalPages; + } +} diff --git a/src/main/java/apptive/devlog/post/dto/UpdatePostRequest.java b/src/main/java/apptive/devlog/post/dto/UpdatePostRequest.java new file mode 100644 index 0000000..1b1336a --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/UpdatePostRequest.java @@ -0,0 +1,30 @@ +package apptive.devlog.post.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UpdatePostRequest { + + @NotBlank + String title; + + @NotBlank + String content; + + List fileUrls = new ArrayList<>(); + + public UpdatePostRequest(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/apptive/devlog/post/exception/BadPostRequestException.java b/src/main/java/apptive/devlog/post/exception/BadPostRequestException.java new file mode 100644 index 0000000..2a1b5b8 --- /dev/null +++ b/src/main/java/apptive/devlog/post/exception/BadPostRequestException.java @@ -0,0 +1,7 @@ +package apptive.devlog.post.exception; + +public class BadPostRequestException extends RuntimeException { + public BadPostRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/post/exception/NotFoundPostException.java b/src/main/java/apptive/devlog/post/exception/NotFoundPostException.java new file mode 100644 index 0000000..27e6ba0 --- /dev/null +++ b/src/main/java/apptive/devlog/post/exception/NotFoundPostException.java @@ -0,0 +1,7 @@ +package apptive.devlog.post.exception; + +public class NotFoundPostException extends RuntimeException { + public NotFoundPostException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/post/exception/PostExceptionHandler.java b/src/main/java/apptive/devlog/post/exception/PostExceptionHandler.java new file mode 100644 index 0000000..8bdfa4e --- /dev/null +++ b/src/main/java/apptive/devlog/post/exception/PostExceptionHandler.java @@ -0,0 +1,30 @@ +package apptive.devlog.post.exception; + + +import apptive.devlog.fileupload.exception.FileUploadException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class PostExceptionHandler { + + @ExceptionHandler(NotFoundPostException.class) + public ResponseEntity> notFound(NotFoundPostException e) { + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", e.getMessage())); + } + + @ExceptionHandler(BadPostRequestException.class) + public ResponseEntity> bad(BadPostRequestException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("message", e.getMessage())); + } + + @ExceptionHandler(FileUploadException.class) + public ResponseEntity> fileUploadEx(FileUploadException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("message", e.getMessage())); + } +} diff --git a/src/main/java/apptive/devlog/post/repository/PostRepository.java b/src/main/java/apptive/devlog/post/repository/PostRepository.java new file mode 100644 index 0000000..d0e6005 --- /dev/null +++ b/src/main/java/apptive/devlog/post/repository/PostRepository.java @@ -0,0 +1,19 @@ +package apptive.devlog.post.repository; + +import apptive.devlog.domain.Member; +import apptive.devlog.domain.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface PostRepository extends JpaRepository { + List findByMember(Member member); + + @Query("select distinct p from Post p left join fetch p.files join fetch p.member where p.id = :id") + Optional findWithFiles(Long id); +} diff --git a/src/main/java/apptive/devlog/post/repository/QPageRepository.java b/src/main/java/apptive/devlog/post/repository/QPageRepository.java new file mode 100644 index 0000000..1b299cb --- /dev/null +++ b/src/main/java/apptive/devlog/post/repository/QPageRepository.java @@ -0,0 +1,51 @@ +package apptive.devlog.post.repository; + +import apptive.devlog.domain.Member; +import apptive.devlog.post.dto.PostResponse; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +import static apptive.devlog.domain.QPost.*; + +@Repository +public class QPageRepository { + + private final JPAQueryFactory queryFactory; + + public QPageRepository(EntityManager em) { + queryFactory = new JPAQueryFactory(em); + } + + public PageImpl findByMemberPage(Member member, Pageable pageable, String title) { + + List contents = queryFactory.selectFrom(post) + .where(post.member.eq(member), titleLike(title)) + .orderBy(post.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch() + .stream().map(p->new PostResponse(p, member.getNickname())).toList(); + + long count = Optional.ofNullable(queryFactory.select(post.count()) + .from(post) + .where(post.member.eq(member), titleLike(title)) + .fetchOne()).orElse(0L); + + return new PageImpl<>(contents, pageable, count); + } + + private BooleanExpression titleLike(String title) { + if (!StringUtils.hasText(title)) return null; + else { + return post.title.like("%" + title + "%"); + } + } +} diff --git a/src/main/java/apptive/devlog/post/service/PostService.java b/src/main/java/apptive/devlog/post/service/PostService.java new file mode 100644 index 0000000..c2f8901 --- /dev/null +++ b/src/main/java/apptive/devlog/post/service/PostService.java @@ -0,0 +1,127 @@ +package apptive.devlog.post.service; + +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.comment.dto.ReCommentResponse; +import apptive.devlog.comment.repository.CommentRepository; +import apptive.devlog.comment.repository.QCommentRepository; +import apptive.devlog.domain.Comment; +import apptive.devlog.domain.Member; +import apptive.devlog.domain.Post; +import apptive.devlog.domain.UploadFile; +import apptive.devlog.fileupload.dto.UploadFileDto; +import apptive.devlog.fileupload.repository.UploadRepository; +import apptive.devlog.fileupload.service.UploadService; +import apptive.devlog.member.exception.NotFoundMemberException; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.post.dto.*; +import apptive.devlog.post.exception.BadPostRequestException; +import apptive.devlog.post.exception.NotFoundPostException; +import apptive.devlog.post.repository.PostRepository; +import apptive.devlog.post.repository.QPageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Transactional +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + private final MemberRepository memberRepository; + private final CommentRepository commentRepository; + private final QCommentRepository qCommentRepository; + private final QPageRepository qPageRepository; + private final UploadRepository uploadRepository; + private final UploadService uploadService; + + public PostResponse save(CreatePostRequest post, String email) { + Member findMember = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundMemberException("존재하지 않는 회원입니다")); + Post saved = postRepository.save(new Post(post.getTitle(), post.getContent(), findMember)); + + uploadFiles(post, findMember, saved); + + return new PostResponse(saved, findMember.getNickname()); + } + + + public void deletePost(Long id, String email) { + Post findPost = postRepository.findWithFiles(id) + .orElseThrow(() -> new NotFoundPostException("해당 게시글이 존재하지 않습니다.")); + + if (!findPost.getMember().getEmail().equals(email)) + throw new BadPostRequestException("자신의 게시글만 삭제할 수 있습니다."); + + uploadService.deleteFiles(findPost.getFiles()); // AWS s3 서버에서 이미지를 삭제 + + postRepository.deleteById(id); + } + + public void updatePost(Long id, String email, UpdatePostRequest post) { + Post findPost = postRepository.findById(id) + .orElseThrow(() -> new NotFoundPostException("해당 게시글이 존재하지 않습니다.")); + + Member findMember = findPost.getMember(); + + if (!findMember.getEmail().equals(email)) + throw new BadPostRequestException("자신의 게시글만 수정할 수 있습니다."); + + + findPost.changeTitle(post.getTitle()); + findPost.changeContent(post.getContent()); + } + + public PostWithCommentResponse findPost(Long id, Pageable pageable) { + Post post = postRepository.findWithFiles(id) + .orElseThrow(() -> new NotFoundPostException("해당 게시글이 존재하지 않습니다")); + + Page pages = qCommentRepository.findParentComment(post.getId(), pageable); + List parents = pages.getContent(); + + List reComments = commentRepository.findReComments(parents); + + + Map> reCommentsMap = reComments.stream() + .collect(Collectors.groupingBy(ReCommentResponse::getParentId)); + + List result = parents.stream(). + map(c -> new CommentResponse(c.getMember().getNickname(), c, reCommentsMap.getOrDefault(c.getId(), new ArrayList<>())) + ).toList(); + + return new PostWithCommentResponse(post, result, pages.getTotalPages()); + } + + + public PostPageResponse findPostsByMember(String nickname, Pageable pageable, String title) { + + + Member findMember = memberRepository.findByNickname(nickname) + .orElseThrow(() -> new NotFoundMemberException("존재하지 않는 회원입니다.")); + + PageImpl pages = qPageRepository.findByMemberPage(findMember, pageable, title); + + return new PostPageResponse(pages.getContent(), pages.getTotalPages()); + } + + + private void uploadFiles(CreatePostRequest post, Member findMember, Post saved) { + List uploadFiles = new ArrayList<>(); + for (UploadFileDto file : post.getFiles()) { + uploadFiles.add(new UploadFile(file.getFileName(), file.getServerFileName(), file.getUrl(), saved, findMember)); + } + + uploadRepository.saveAll(uploadFiles); + } + + +} diff --git a/src/main/resources/application-aws.properties b/src/main/resources/application-aws.properties new file mode 100644 index 0000000..241d05c --- /dev/null +++ b/src/main/resources/application-aws.properties @@ -0,0 +1,6 @@ + + +s3.credentials.access-key:${AWS_ACCESS_KEY} +s3.credentials.secret-key:${AWS_SECRET_KEY} +s3.bucket:jinwonawsbucket +s3.credentials.region: ap-southeast-2 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 95619f3..71a21f0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,9 @@ -spring.application.name=devlog +spring.datasource.url=jdbc:mysql://127.0.0.1:3306/devlog?serverTimezone=Asia/Seoul +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.datasource.username=root +spring.datasource.password=test1234 +spring.jpa.hibernate.ddl-auto=update + +spring.profiles.include=aws + +spring.jwt.secret="${JWT_KEY}" diff --git a/src/test/java/apptive/devlog/comment/controller/CommentControllerTest.java b/src/test/java/apptive/devlog/comment/controller/CommentControllerTest.java new file mode 100644 index 0000000..902e54e --- /dev/null +++ b/src/test/java/apptive/devlog/comment/controller/CommentControllerTest.java @@ -0,0 +1,124 @@ +package apptive.devlog.comment.controller; + + +import apptive.devlog.comment.dto.CommentRequest; +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.comment.service.CommentService; +import apptive.devlog.domain.Gender; +import apptive.devlog.domain.Member; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.jwt.JWTUtil; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.member.service.MemberService; +import apptive.devlog.post.dto.CreatePostRequest; +import apptive.devlog.post.dto.PostResponse; +import apptive.devlog.post.service.PostService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CommentControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + CommentService commentService; + + @Autowired + MemberService memberService; + + @Autowired + PostService postService; + + @Autowired + JWTUtil jwtUtil; + + @Autowired + ObjectMapper objectMapper; + + @Test + void createComment() throws Exception { + String accessToken = makeAccessToken(); + + CreatePostRequest postRequest = new CreatePostRequest("제목1", "내용1"); + + PostResponse response = postService.save(postRequest, "ljw2109@naver.com"); + + CommentRequest comment = new CommentRequest("댓글1"); + + mockMvc.perform(post("/users/me/post/{id}/comment", response.getId()) + .header("access", accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(comment))) + .andExpect(status().isCreated()); + } + + @Test + void deleteComment() throws Exception { + String accessToken = makeAccessToken(); + + CreatePostRequest postRequest = new CreatePostRequest("제목1", "내용1"); + + PostResponse response = postService.save(postRequest, "ljw2109@naver.com"); + + CommentRequest comment = new CommentRequest("댓글1"); + + CommentResponse savedComment = commentService.saveComment(comment, response.getId(), "ljw2109@naver.com"); + + mockMvc.perform(delete("/users/me/comment/{id}",savedComment.getId()) + .header("access", accessToken) + ).andExpect(status().isNoContent()); + } + + @Test + void updateComment() throws Exception { + String accessToken = makeAccessToken(); + + CreatePostRequest postRequest = new CreatePostRequest("제목1", "내용1"); + + PostResponse response = postService.save(postRequest, "ljw2109@naver.com"); + + CommentRequest comment = new CommentRequest("댓글1"); + + CommentResponse savedComment = commentService.saveComment(comment, response.getId(), "ljw2109@naver.com"); + + CommentRequest updateComment = new CommentRequest("수정된 댓글"); + + mockMvc.perform(patch("/users/me/comment/{id}",savedComment.getId()) + .header("access", accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(updateComment)) + ).andExpect(status().isOk()); + } + + private String makeAccessToken() { + JoinForm joinForm = joinTestCase(); + + memberService.join(joinForm); + return jwtUtil.createJWT("access", joinForm.getEmail(), "ROLE_MEMBER", 600000L); + } + + + private JoinForm joinTestCase() { + JoinForm joinForm = + new JoinForm("ljw2109@naver.com", "Qwer1234!!","Qwer1234!!", "이진원", + "이진원", LocalDate.of(2001,6,26), Gender.MALE); + return joinForm; + } + +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/comment/service/CommentServiceTest.java b/src/test/java/apptive/devlog/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..daa927c --- /dev/null +++ b/src/test/java/apptive/devlog/comment/service/CommentServiceTest.java @@ -0,0 +1,159 @@ +package apptive.devlog.comment.service; + +import apptive.devlog.comment.dto.CommentRequest; +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.comment.repository.CommentRepository; +import apptive.devlog.domain.Comment; +import apptive.devlog.domain.Gender; +import apptive.devlog.domain.Member; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.member.service.MemberService; +import apptive.devlog.post.dto.CreatePostRequest; +import apptive.devlog.post.dto.PostResponse; +import apptive.devlog.post.dto.PostWithCommentResponse; +import apptive.devlog.post.service.PostService; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; + + +@SpringBootTest +@Transactional +class CommentServiceTest { + + @Autowired + CommentService commentService; + + @Autowired + MemberService memberService; + + @Autowired + MemberRepository memberRepository; + + @Autowired + CommentRepository commentRepository; + + @Autowired + EntityManager em; + + @Autowired + PostService postService; + + @Test + void createComments() { + Member member = makeMember(); + PostResponse post = postService.save(new CreatePostRequest("제목1", "내용1"), member.getEmail()); + + for (int i=1; i<=100; i++) { + CommentRequest comment = new CommentRequest("내용"+i); + commentService.saveComment(comment,post.getId(),member.getEmail()); + } + + flushAndClear(); + + PostWithCommentResponse findPost = postService.findPost(post.getId(), PageRequest.of(0,10)); + + assertThat(findPost.getComments().size()).isEqualTo(10); + } + + @Test + void createReComments() { + Member member = makeMember(); + PostResponse post = postService.save(new CreatePostRequest("제목1", "내용1"), member.getEmail()); + CommentRequest comment = new CommentRequest("내용1"); + CommentResponse response = commentService.saveComment(comment, post.getId(), member.getEmail()); + + for (int i=1; i<=10; i++) { + CommentResponse reComment = commentService.saveReComment(new CommentRequest("내용" + i), post.getId(), response.getId(), member.getEmail()); + System.out.println(reComment.getContent()); + } + + flushAndClear(); + + Comment findComment = commentRepository.findById(response.getId()).get(); + + + + assertThat(findComment.getComments().size()).isEqualTo(10); + } + + @Test + @DisplayName("대댓글이 존재하는 부모댓글은 soft Delete") + void deleteCommentWithChild() { + Member member = makeMember(); + PostResponse post = postService.save(new CreatePostRequest("제목1", "내용1"), member.getEmail()); + CommentRequest comment = new CommentRequest("내용1"); + CommentResponse savedComment = commentService.saveComment(comment, post.getId(), member.getEmail()); + CommentRequest reComment = new CommentRequest("대댓글"); + commentService.saveReComment(reComment, post.getId(), savedComment.getId(), member.getEmail()); + + flushAndClear(); + + commentService.deleteComment(savedComment.getId(), member.getEmail()); + + Comment findComment = commentRepository.findById(savedComment.getId()).get(); + + assertThat(findComment.isDeleted()).isTrue(); + } + + @Test + @DisplayName("soft Delete 후 No Child인 Comment 자동 삭제") + void softDeleteComment() { + Member member = makeMember(); + PostResponse post = postService.save(new CreatePostRequest("제목1", "내용1"), member.getEmail()); + CommentRequest comment = new CommentRequest("내용1"); + CommentResponse savedComment = commentService.saveComment(comment, post.getId(), member.getEmail()); + CommentRequest reComment = new CommentRequest("대댓글"); + CommentResponse saveReComment = commentService.saveReComment(reComment, post.getId(), savedComment.getId(), member.getEmail()); + + flushAndClear(); + + commentService.deleteComment(savedComment.getId(), member.getEmail()); + commentService.deleteComment(saveReComment.getId(), member.getEmail()); + + assertThat(commentRepository.findById(savedComment.getId())).isEmpty(); + } + + @Test + void updateComment() { + Member member = makeMember(); + PostResponse post = postService.save(new CreatePostRequest("제목1", "내용1"), member.getEmail()); + CommentRequest comment = new CommentRequest("내용1"); + CommentResponse savedComment = commentService.saveComment(comment, post.getId(), member.getEmail()); + commentService.updateComment(new CommentRequest("수정된 댓글"), savedComment.getId(), member.getEmail()); + + flushAndClear(); + + Comment updatedComment = commentRepository.findById(savedComment.getId()).get(); + + assertThat(updatedComment.getContent()).isEqualTo("수정된 댓글"); + } + + private void flushAndClear() { + em.flush(); + em.clear(); + } + + private Member makeMember() { + JoinForm joinForm = joinTestCase(); + memberService.join(joinForm); + return memberRepository.findByNickname(joinForm.getNickname()).orElse(null); + } + + private JoinForm joinTestCase() { + JoinForm joinForm = + new JoinForm("ljw2109@naver.com", "Qwer1234!!","Qwer1234!!", "이진원", + "이진원", LocalDate.of(2001,6,26), Gender.MALE); + return joinForm; + } + +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/member/controller/MemberControllerTest.java b/src/test/java/apptive/devlog/member/controller/MemberControllerTest.java new file mode 100644 index 0000000..4831596 --- /dev/null +++ b/src/test/java/apptive/devlog/member/controller/MemberControllerTest.java @@ -0,0 +1,167 @@ +package apptive.devlog.member.controller; + +import apptive.devlog.domain.Gender; +import apptive.devlog.domain.Member; +import apptive.devlog.domain.RefreshEntity; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.dto.LoginForm; +import apptive.devlog.member.dto.MemberDetails; +import apptive.devlog.member.dto.MemberUpdateForm; +import apptive.devlog.member.jwt.JWTUtil; +import apptive.devlog.member.repository.RefreshRepository; +import apptive.devlog.member.service.MemberService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +public class MemberControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + @Autowired + private JWTUtil jwtUtil; + + @Autowired + private MemberService memberService; + + @Autowired + private RefreshRepository refreshRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void login() throws Exception { + JoinForm joinForm = joinTestCase(); + + LoginForm loginForm = new LoginForm(joinForm.getEmail(), joinForm.getPassword()); + + String json = objectMapper.writeValueAsString(loginForm); + + mockMvc.perform(post("/login") + .contentType("application/json") + .content(json)) + .andExpect(status().isOk()) + .andExpect(header().exists("access")) + .andExpect(cookie().exists("refresh")); + } + + @Test + void logout() throws Exception { + + JoinForm joinForm = joinTestCase(); + + String refresh = jwtUtil.createJWT("refresh", joinForm.getEmail(), "ROLE_MEMBER", 86400000L); + + addRefreshEntity(joinForm.getEmail(), refresh, 86400000L); + + mockMvc.perform(post("/logout") + .cookie(createCookie("refresh",refresh))) + .andExpect(status().isOk()); + } + + @Test + void withdrawMember() throws Exception { + JoinForm joinForm = joinTestCase(); + + String access = jwtUtil.createJWT("access", joinForm.getEmail(), "ROLE_MEMBER", 600000L); + + mockMvc.perform(delete("/users/me") + .contentType("application/json") + .header("access", access)) + .andExpect(status().isNoContent()); + } + + @Test + void updateMember() throws Exception { + JoinForm joinForm = joinTestCase(); + + String access = jwtUtil.createJWT("access", joinForm.getEmail(), "ROLE_MEMBER", 600000L); + + MemberUpdateForm updateForm = new MemberUpdateForm("이진원", "Qwer1234!!", "Qwer5678!!", "Qwer5678!!", + LocalDate.of(2020, 1, 1), Gender.FEMALE); + + mockMvc.perform(patch("/users/me") + .contentType("application/json") + .content(objectMapper.writeValueAsString(updateForm)) + .header("access", access)) + .andExpect(status().isOk()); + + MemberDetails memberDetails= (MemberDetails) memberService.loadUserByUsername(joinForm.getEmail()); + Member findMember = memberDetails.getMember(); + + assertThat(findMember.getNickname()).isEqualTo(updateForm.getNickname()); + assertThat(findMember.getGender()).isEqualTo(updateForm.getGender()); + assertThat(passwordEncoder.matches(updateForm.getConfirmPassword(), findMember.getPassword())).isTrue(); + assertThat(findMember.getBirthdate()).isEqualTo(updateForm.getBirthdate()); + } + + @Test + void reissue() throws Exception { + JoinForm joinForm = joinTestCase(); + + String refresh = jwtUtil.createJWT("refresh", joinForm.getEmail(), "ROLE_MEMBER", 86400000L); + + addRefreshEntity(joinForm.getEmail(), refresh, 86400000L); + + mockMvc.perform(post("/reissue") + .contentType("application/json") + .cookie(createCookie("refresh", refresh))) + .andExpect(status().isOk()) + .andExpect(header().exists("access")) + .andExpect(cookie().exists("refresh")); + } + + @Test + void reissueFail() throws Exception { + + mockMvc.perform(post("/reissue") + .contentType("application/json") + .cookie(new Cookie("fail", "fail"))) + .andExpect(status().isBadRequest()); + } + + private JoinForm joinTestCase() { + JoinForm joinForm = + new JoinForm("ljw2109@naver.com", "Qwer1234!!","Qwer1234!!", "이진원", + "이진원", LocalDate.of(2001,6,26), Gender.MALE); + + memberService.join(joinForm); + return joinForm; + } + + private void addRefreshEntity(String email, String refresh, Long expiredMs) { + Date date = new Date(System.currentTimeMillis() + expiredMs); + + RefreshEntity refreshEntity = new RefreshEntity(email, refresh, date.toString()); + + refreshRepository.save(refreshEntity); + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(24*60*60); + cookie.setHttpOnly(true); + return cookie; + } +} diff --git a/src/test/java/apptive/devlog/member/service/MemberServiceTest.java b/src/test/java/apptive/devlog/member/service/MemberServiceTest.java new file mode 100644 index 0000000..297d559 --- /dev/null +++ b/src/test/java/apptive/devlog/member/service/MemberServiceTest.java @@ -0,0 +1,100 @@ +package apptive.devlog.member.service; + +import apptive.devlog.domain.Gender; +import apptive.devlog.domain.Member; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.dto.MemberDetails; +import apptive.devlog.member.dto.MemberUpdateForm; +import apptive.devlog.member.exception.NotFoundMemberException; +import apptive.devlog.member.exception.PasswordException; +import apptive.devlog.member.repository.RefreshRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + + +@SpringBootTest +@Transactional +class MemberServiceTest { + + @Autowired + MemberService memberService; + + @Autowired + RefreshRepository refreshRepository; + + @Autowired + BCryptPasswordEncoder passwordEncoder; + + @Test + void join() { + JoinForm joinForm = joinTestCase(); + + MemberDetails findMember = (MemberDetails) memberService.loadUserByUsername(joinForm.getEmail()); + + assertThat(findMember.getUsername()).isEqualTo(joinForm.getEmail()); + } + + + @Test + void updateMember() { + JoinForm joinForm = joinTestCase(); + MemberUpdateForm updateForm = new MemberUpdateForm("이진원", "Qwer1234!!", "Qwer5678!!", "Qwer5678!!", + LocalDate.of(2020, 1, 1), Gender.FEMALE); + + memberService.update(joinForm.getEmail(), updateForm); + + MemberDetails memberDetails= (MemberDetails) memberService.loadUserByUsername(joinForm.getEmail()); + Member findMember = memberDetails.getMember(); + + assertThat(findMember.getNickname()).isEqualTo(updateForm.getNickname()); + assertThat(findMember.getGender()).isEqualTo(updateForm.getGender()); + assertThat(passwordEncoder.matches(updateForm.getConfirmPassword(), findMember.getPassword())).isTrue(); + assertThat(findMember.getBirthdate()).isEqualTo(updateForm.getBirthdate()); + } + + @Test + void updateMemberException() { + JoinForm joinForm = joinTestCase(); + MemberUpdateForm updateForm = new MemberUpdateForm("이진원", "Qwer1234!!", "Qwer56781!!", "Qwer5678!!", + LocalDate.of(2020, 1, 1), Gender.FEMALE); + + + assertThatThrownBy(()-> memberService.update(joinForm.getEmail(), updateForm)) + .isInstanceOf(PasswordException.class); + } + + @Test + void withdraw() { + JoinForm joinForm = joinTestCase(); + memberService.withdraw(joinForm.getEmail()); + + assertThatThrownBy(()->memberService.loadUserByUsername(joinForm.getEmail())).isInstanceOf(UsernameNotFoundException.class); + assertThat(refreshRepository.existsByEmail(joinForm.getEmail())).isEqualTo(false); + } + + @Test + void withdrawException() { + String email = "notExistEmail@naver.com"; + assertThatThrownBy(()->memberService.withdraw(email)) + .isInstanceOf(NotFoundMemberException.class); + } + + private JoinForm joinTestCase() { + JoinForm joinForm = + new JoinForm("ljw2109@naver.com", "Qwer1234!!","Qwer1234!!", "이진원", + "이진원", LocalDate.of(2001,6,26), Gender.MALE); + + memberService.join(joinForm); + return joinForm; + } + +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/post/controller/PostControllerTest.java b/src/test/java/apptive/devlog/post/controller/PostControllerTest.java new file mode 100644 index 0000000..94a88c5 --- /dev/null +++ b/src/test/java/apptive/devlog/post/controller/PostControllerTest.java @@ -0,0 +1,143 @@ +package apptive.devlog.post.controller; + +import apptive.devlog.domain.Gender; +import apptive.devlog.domain.Member; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.jwt.JWTUtil; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.member.service.MemberService; +import apptive.devlog.post.dto.CreatePostRequest; + +import apptive.devlog.post.dto.PostResponse; +import apptive.devlog.post.dto.PostWithCommentResponse; +import apptive.devlog.post.dto.UpdatePostRequest; +import apptive.devlog.post.service.PostService; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; + +import org.springframework.test.web.servlet.MockMvc; + +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class PostControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + JWTUtil jwtUtil; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MemberService memberService; + + @Autowired + MemberRepository memberRepository; + + @Autowired + PostService postService; + + + @Test + void createPost() throws Exception { + String access = makeAccessToken(); + + CreatePostRequest post = new CreatePostRequest("제목1", "내용1", new ArrayList<>()); + + mockMvc.perform(post("/users/me/post") + .contentType("application/json").content(objectMapper.writeValueAsString(post)) + .header("access", access)) + .andExpect(status().isCreated()); + } + + @Test + void getPosts() throws Exception { + Member member = makeMember(); + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + + PostResponse response = postService.save(post, member.getEmail()); + + mockMvc.perform(get("/users/post/{id}", response.getId())) + .andExpect(status().isOk()); + + } + + @Test + void updatePost() throws Exception { + String accessToken = makeAccessToken(); + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + PostResponse response = postService.save(post,"ljw2109@naver.com"); + UpdatePostRequest updatePostRequest = new UpdatePostRequest("제목2", "내용2"); + + + mockMvc.perform(patch("/users/me/post/{id}", response.getId()) + .header("access", accessToken) + .contentType("application/json") + .content(objectMapper.writeValueAsString(updatePostRequest)) + ).andExpect(status().isOk()); + } + + @Test + void deletePost() throws Exception { + String accessToken = makeAccessToken(); + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + PostResponse response = postService.save(post,"ljw2109@naver.com"); + + mockMvc.perform(delete("/users/me/post/{id}" ,response.getId()) + .header("access", accessToken)) + .andExpect(status().isNoContent()); + } + + + @Test + @DisplayName("회원 페이지의 게시글 보기") + void showMemberPosts() throws Exception { + JoinForm joinForm = joinTestCase(); + memberService.join(joinForm); + + mockMvc.perform(get("/users/{nickname}/post", joinForm.getNickname()) + .contentType("application/json") + ) + .andExpect(status().isOk()); + } + + + private String makeAccessToken() { + JoinForm joinForm = joinTestCase(); + + memberService.join(joinForm); + return jwtUtil.createJWT("access", joinForm.getEmail(), "ROLE_MEMBER", 600000L); + } + + private static JoinForm joinTestCase() { + JoinForm joinForm = + new JoinForm("ljw2109@naver.com", "Qwer1234!!","Qwer1234!!", "이진원", + "이진원", LocalDate.of(2001,6,26), Gender.MALE); + return joinForm; + } + + private Member makeMember() { + JoinForm joinForm = joinTestCase(); + memberService.join(joinForm); + return memberRepository.findByNickname(joinForm.getNickname()).orElse(null); + } + + +} \ No newline at end of file diff --git a/src/test/java/apptive/devlog/post/service/PostServiceTest.java b/src/test/java/apptive/devlog/post/service/PostServiceTest.java new file mode 100644 index 0000000..ce867d1 --- /dev/null +++ b/src/test/java/apptive/devlog/post/service/PostServiceTest.java @@ -0,0 +1,123 @@ +package apptive.devlog.post.service; + +import apptive.devlog.comment.dto.CommentRequest; +import apptive.devlog.comment.dto.CommentResponse; +import apptive.devlog.domain.Gender; +import apptive.devlog.domain.Member; +import apptive.devlog.domain.Post; +import apptive.devlog.member.dto.JoinForm; +import apptive.devlog.member.repository.MemberRepository; +import apptive.devlog.member.service.MemberService; +import apptive.devlog.post.dto.CreatePostRequest; +import apptive.devlog.post.dto.PostResponse; +import apptive.devlog.post.dto.PostWithCommentResponse; +import apptive.devlog.post.dto.UpdatePostRequest; +import apptive.devlog.post.repository.PostRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; + + +@SpringBootTest +@Transactional +class PostServiceTest { + + @Autowired + PostService postService; + + @Autowired + PostRepository postRepository; + + @Autowired + MemberService memberService; + @Autowired + MemberRepository memberRepository; + + @Test + void createPost() { + + Member member = makeMember(); + + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + PostResponse response = postService.save(post, member.getEmail()); + + assertThat(response.getAuthor()).isEqualTo(member.getNickname()); + } + + @Test + void deletePost() { + Member member = makeMember(); + + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + PostResponse response = postService.save(post, member.getEmail()); + + postService.deletePost(response.getId(), member.getEmail()); + + assertThat(postRepository.findById(response.getId())).isEmpty(); + } + + @Test + void updatePost() { + Member member = makeMember(); + + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + PostResponse response = postService.save(post, member.getEmail()); + + UpdatePostRequest updatePost = new UpdatePostRequest("제목2", "내용2"); + + postService.updatePost(response.getId(),member.getEmail(), updatePost); + + Post findPost = postRepository.findById(response.getId()).get(); + + assertThat(findPost.getTitle()).isEqualTo(updatePost.getTitle()); + assertThat(findPost.getContent()).isEqualTo(updatePost.getContent()); + } + + @Test + void getPost() { + Member member = makeMember(); + CreatePostRequest post = new CreatePostRequest("제목1", "내용1"); + PostResponse response = postService.save(post, member.getEmail()); + + PostWithCommentResponse findResponse = postService.findPost(response.getId(), PageRequest.of(1,30)); + + assertThat(findResponse.getId()).isEqualTo(response.getId()); + assertThat(findResponse.getAuthor()).isEqualTo(member.getNickname()); + } + + @Test + void showMemberPosts() { + Member member = makeMember(); + makePosts(member); + + assertThat(postService.findPostsByMember(member.getNickname(), PageRequest.of(0,20), null).getPosts().size()).isEqualTo(10); + } + + private void makePosts(Member member) { + for (int i = 1; i <= 10; i++) { + postService.save(new CreatePostRequest("제목"+ i, "내용" + i), member.getEmail()); + } + } + + + + private Member makeMember() { + JoinForm joinForm = joinTestCase(); + memberService.join(joinForm); + return memberRepository.findByNickname(joinForm.getNickname()).orElse(null); + } + + private JoinForm joinTestCase() { + JoinForm joinForm = + new JoinForm("ljw2109@naver.com", "Qwer1234!!","Qwer1234!!", "이진원", + "이진원", LocalDate.of(2001,6,26), Gender.MALE); + return joinForm; + } +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..ad5422f --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,3 @@ +spring.jwt.secret="${JWT_KEY}" +spring.jpa.show-sql=true +spring.profiles.include=aws \ No newline at end of file