Skip to content

Commit

Permalink
feat#23/유저 생성 기능 구현 및 설정 작성 (#29)
Browse files Browse the repository at this point in the history
* feat: BaseEntity 클래스 생성

모든 엔티티에서 상속받아서 사용하는 Audit 클래스로 createdAt, updatedAt 이 포함됨

상속받아서 사용하는 경우 jpa 에 자동으로 생성날짜, 수정날짜가 추가됨

* feat: Member Jpa 엔티티 클래스 추가

* feat: MemberRepository 클래스 생성

Jpa 를 상속받아 db 접근하는 레포지토리 생성

* feat: MemberService 인터페이스 생성

Member 도메인의 비즈니스 로직에 대한 인터페이스

* feat: MemberCreateDto 클래스 생성

Member 생성 서비스 메소드의 인자를 넘겨주는 dto 생성

record 로 구현하여 불변성 보장

* feat: MemberCreateDto 를 Member 엔티티로 변환하는 메소드 추가

Jpa Entity 로 변환하기 위한 toEntity 메소드 작성

* feat: MemberService 를 클래스로 변환 후 회원가입 서비스 로직 작성

서비스에 인터페이스를 도입하지 않기로 결정함에 따라 인터페이스 삭제 후 MemberService 의 createMember 메소드 작성

MemberCreateDto 를 받아 Member 엔티티로 변환 후 레포지토리를 통해 저장하도록 함

* feat: Member 컨트롤러 클래스 추가 및 회원가입 메소드 작성

MemberCreateDto 를 인자로 받아 MemberService 호출하는 회원가입 로직 코드 추가

추후에 공통 응답 클래스로 변경 필요

* fix: querydsl build.gradle 설정 수정

querydsl 설정이 springboot 3.x 부터 달라져 생긴 QClass 생성 오류 해결

참고 https://lordofkangs.tistory.com/461

* refactor: audit 관련 클래스를 `global.audit` 패키지로 이동

- audit와 관련된 모든 클래스를 `global.audit` 패키지로 이동했습니다.
- 새로운 패키지 구조를 반영하도록 import 문과 참조를 업데이트했습니다.

* build: jacoco 설정 git conflict 수정

- 제외 클래스, 패키지 추가

* fix: 패키지 이동에 따른 import 누락 수정

- import 문을 정확히 명시

* refactor: memberName을 name으로 변수명 변경

- name으로 하고 테이블 명으로 구분해줬습니다.

* refactor: memberService 검증 로직 추가

- 중복된 이메일은 가입 할 수 없도록 로직을 추가했습니다.

* refactor: 주석 삭제 및 상태 응답 코드 변경

- 200 OK -> 201 Created로 맥락에 맞게 변경
- 주석 삭제

* test: 테스트 환경, DB cleaner 구현

- application.yml test 환경 추가
- TestDB 클리너 구현

* test: Member 생성 로직 테스트 구현

- Controller 테스트 구현 및 RestDocs 작성
- Service 로직 테스트 구현

* build: build.gradle git conflict 해결

- global/api 및 exception 제외

* ci: Github Action 실행 환경을 Docker로 변경

- DB가 없어 테스트를 수행할 수 없어서 도커로 환경 설정

* chore: Github Action에 깔린 도커로 변경

* build: 빌드 시 h2 데이터베이스 환경으로 테스트가 돌아 갈 수 있도록 수정

- 도커는 로컬로만 쓰고 h2로 빌드하도록 수정

---------

Co-authored-by: KyuWon  Kim <[email protected]>
Co-authored-by: HyeonJun0530 <[email protected]>
  • Loading branch information
3 people authored Aug 13, 2024
1 parent 58ed114 commit 21aab93
Show file tree
Hide file tree
Showing 13 changed files with 340 additions and 25 deletions.
9 changes: 2 additions & 7 deletions .github/workflows/jacoco-rule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ name: jacoco-rule
on:
pull_request:
branches: [ main, develop ]

permissions:
pull-requests: write
contents: read


jobs:
build:
runs-on: ubuntu-latest
Expand All @@ -28,9 +27,6 @@ jobs:
- name: Build with Gradle
run: ./gradlew build

- name: Run tests and generate Jacoco report
run: ./gradlew test jacocoTestReport jacocoTestCoverageVerification

- name: Upload Jacoco coverage report
uses: actions/upload-artifact@v2
with:
Expand All @@ -52,11 +48,10 @@ jobs:
run: |
overall_coverage=$(echo "${{ steps.jacoco.outputs.coverage-overall }}" | cut -d'.' -f1)
changed_files_coverage=$(echo "${{ steps.jacoco.outputs.coverage-changed-files }}" | cut -d'.' -f1)
if [ $overall_coverage -lt 80 ] || [ $changed_files_coverage -lt 80 ]; then
echo "Coverage is below the required threshold."
echo "Overall coverage: $overall_coverage%"
echo "Changed files coverage: $changed_files_coverage%"
exit 1
fi
43 changes: 25 additions & 18 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ plugins {
id 'io.spring.dependency-management' version '1.1.6'
id 'org.asciidoctor.jvm.convert' version '3.3.2'
id 'jacoco'
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'com.wootecam'
Expand Down Expand Up @@ -41,6 +40,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// Rest docs
Expand Down Expand Up @@ -92,7 +92,12 @@ jacocoTestCoverageVerification {
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/FestivalsApplication.class', '**/global/exception/**', '**/global/api/**' // exclude main class
'**/FestivalsApplication.class', // exclude main class
'**/global/audit/**', // exclude audit package
'**/dto/**', // exclude dto package
'**/Q**', // exclude QueryDSL Q classes
'**/global/exception/**', // exclude exception package
'**/global/api/**', // exclude api package
])
}))
}
Expand All @@ -113,11 +118,6 @@ jacocoTestCoverageVerification {
}
}

test {
finalizedBy jacocoTestReport
finalizedBy jacocoTestCoverageVerification
}

jacocoTestReport {
dependsOn test
reports {
Expand All @@ -128,25 +128,32 @@ jacocoTestReport {
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/FestivalsApplication.class', '**/global/exception/**', '**/global/api/**' // exclude main class
'**/FestivalsApplication.class', // exclude main class
'**/global/audit/**', // exclude audit package
'**/dto/**', // exclude dto package
'**/Q**', // exclude QueryDSL Q classes
'**/global/exception/**', // exclude exception package
'**/global/api/**', // exclude api package
])
}))
}
}

// queryDSL 추가 : QueryDSL 빌드 옵션
test {
finalizedBy jacocoTestReport
finalizedBy jacocoTestCoverageVerification
}

def querydslDir = "$buildDir/generated/querydsl"

querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
main.java.srcDirs += [querydslDir]
}
configurations {
querydsl.extendsFrom compileClasspath

tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl

clean.doLast {
file(querydslDir).deleteDir()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.wootecam.festivals.domain.member.controller;

import com.wootecam.festivals.domain.member.dto.MemberCreateDto;
import com.wootecam.festivals.domain.member.service.MemberService;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/member")
@RequiredArgsConstructor
public class MemberController {

private final MemberService memberService;

// 유저 회원가입
@PostMapping
public ResponseEntity<Long> signUpMember(@RequestBody MemberCreateDto memberCreateDto) {
return new ResponseEntity<>(memberService.createMember(memberCreateDto), HttpStatus.CREATED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.wootecam.festivals.domain.member.dto;

import com.wootecam.festivals.domain.member.entity.Member;

public record MemberCreateDto(String name, String email, String profileImg) {
public Member toEntity() {
return Member.builder()
.name(this.name)
.email(this.email)
.profileImg(this.profileImg)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.wootecam.festivals.domain.member.entity;

import com.wootecam.festivals.global.audit.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(name = "member_name")
private String name;
private String email;
private String profileImg;
private boolean isDeleted;

@Builder
private Member(String name, String email, String profileImg) {
this.name = Objects.requireNonNull(name, "memberName must be provided.");
this.email = Objects.requireNonNull(email, "email must be provided.");
this.profileImg = profileImg;
this.isDeleted = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.wootecam.festivals.domain.member.repository;

import com.wootecam.festivals.domain.member.entity.Member;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findByEmail(String email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.wootecam.festivals.domain.member.service;

import com.wootecam.festivals.domain.member.dto.MemberCreateDto;
import com.wootecam.festivals.domain.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class MemberService {

private final MemberRepository memberRepository;

public Long createMember(MemberCreateDto memberCreateDto) {

memberRepository.findByEmail(memberCreateDto.email())
.ifPresent(member -> {
throw new IllegalArgumentException("이미 존재하는 회원입니다.");
});

return memberRepository
.save(memberCreateDto.toEntity())
.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.wootecam.festivals.global.audit;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class AuditingConfig {
}
24 changes: 24 additions & 0 deletions src/main/java/com/wootecam/festivals/global/audit/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.wootecam.festivals.global.audit;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;

@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
32 changes: 32 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,35 @@ logging:
level:
org.hibernate.SQL: debug
org.hibernate.type: trace

---
spring:
config:
activate:
on-profile: test
datasource:
url: jdbc:h2:mem:twodari;MODE=MySQL
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
data:
redis:
host: redis
port: 6379

server:
port: 8080

logging:
level:
org.hibernate.SQL: debug
org.hibernate.type: trace

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.wootecam.festivals.domain.member.controller;

import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.wootecam.festivals.docs.utils.RestDocsSupport;
import com.wootecam.festivals.domain.member.dto.MemberCreateDto;
import com.wootecam.festivals.domain.member.service.MemberService;
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.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.context.ActiveProfiles;

@WebMvcTest(MemberController.class)
@ActiveProfiles("test")
class MemberControllerTest extends RestDocsSupport {

@MockBean
private MemberService memberService;

@Autowired
private MemberController memberController;

@Override
protected Object initController() {
return memberController;
}

@Test
@DisplayName("회원가입 테스트")
void createMember() throws Exception {
// given
String name = "test";
String email = "[email protected]";
String profileImg = "test";

// when
this.mockMvc.perform(post("/api/v1/member")
.content(objectMapper.writeValueAsString(new MemberCreateDto(name, email, profileImg)))
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(status().isCreated())
.andDo(restDocs.document(
requestFields(
fieldWithPath("name").type(JsonFieldType.STRING).description("The name of the member")
.attributes(field("constraints", "Must not be null")),
fieldWithPath("email").type(JsonFieldType.STRING).description("The email of the member")
.attributes(field("constraints", "Must not be null")),
fieldWithPath("profileImg").type(JsonFieldType.STRING)
.description("The profile image of the member")
.attributes(field("constraints", "Must not be null"))
))
);
}
}
Loading

0 comments on commit 21aab93

Please sign in to comment.