diff --git a/.github/workflows/type-labeler.yml b/.github/workflows/type-labeler.yml index 7724aec9..6b2d7524 100644 --- a/.github/workflows/type-labeler.yml +++ b/.github/workflows/type-labeler.yml @@ -16,61 +16,61 @@ jobs: - name: add ✨ feature label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'feat:') }} + if: startsWith(github.event.pull_request.title, 'feat:') with: labels: ✨ feature - name: add πŸ› bug-fix label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'fix:') }} + if: startsWith(github.event.pull_request.title, 'fix:') with: labels: πŸ› bug-fix - name: add ♻️ refactor label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'refactor:') }} + if: startsWith(github.event.pull_request.title, 'refactor:') with: labels: ♻️ refactor - name: add 🎨 style label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'style:') }} + if: startsWith(github.event.pull_request.title, 'style:') with: labels: 🎨 style - name: add πŸ—οΈ build label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'chore:') }} + if: startsWith(github.event.pull_request.title, 'chore:') with: labels: πŸ—οΈ build - name: add βœ… test label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'test:') }} + if: startsWith(github.event.pull_request.title, 'test:') with: labels: βœ… test - name: add πŸ“ docs label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'docs:') }} + if: startsWith(github.event.pull_request.title, 'docs:') with: labels: πŸ“ docs - name: add πŸ‘· ci label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'ci:') }} + if: startsWith(github.event.pull_request.title, 'ci:') with: labels: πŸ‘· ci - name: add ⚑️ perf label uses: actions-ecosystem/action-add-labels@v1 - if: startsWith(github.event.pull_request.title, 'perf:') }} + if: startsWith(github.event.pull_request.title, 'perf:') with: labels: ⚑️ perf - name: add πŸ”– release label uses: actions-ecosystem/action-add-labels@v1 - if: ${{ startsWith(github.event.pull_request.title, 'release:') }} + if: ${{ startsWith(github.event.pull_request.title, 'release:') with: labels: πŸ”– release diff --git a/build.gradle b/build.gradle index 4739fb94..c06112a7 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ plugins { apply from: "gradle/spring.gradle" apply from: "gradle/devtool.gradle" -apply from: "gradle/junit.gradle" +apply from: "gradle/test.gradle" apply from: "gradle/sonar.gradle" apply from: "gradle/db.gradle" diff --git a/gradle.properties b/gradle.properties index 2d2417f2..a6f7cdc0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,8 +2,9 @@ projectGroup=net.teumteum projectVersion=1.0 -### JUNIT ### +### TEST ### junitVersion=5.10.1 +assertJVersion=3.24.2 ### LOMBOK ### lombokVersion=1.18.30 diff --git a/gradle/junit.gradle b/gradle/test.gradle similarity index 83% rename from gradle/junit.gradle rename to gradle/test.gradle index eb69259e..291ae524 100644 --- a/gradle/junit.gradle +++ b/gradle/test.gradle @@ -9,5 +9,7 @@ allprojects { testImplementation "org.junit.jupiter:junit-jupiter-api:${junitVersion}" testImplementation "org.junit.jupiter:junit-jupiter-params:${junitVersion}" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" + + testImplementation "org.assertj:assertj-core:${assertJVersion}" } } diff --git a/src/main/java/net/teumteum/Application.java b/src/main/java/net/teumteum/Application.java index 499b5702..7f388f2d 100644 --- a/src/main/java/net/teumteum/Application.java +++ b/src/main/java/net/teumteum/Application.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class Application { diff --git a/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java b/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java new file mode 100644 index 00000000..6df9785a --- /dev/null +++ b/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java @@ -0,0 +1,35 @@ +package net.teumteum.core.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import java.time.Instant; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@MappedSuperclass +public abstract class TimeBaseEntity { + + @Column(name = "created_at", columnDefinition = "TIMESTAMP(6)", nullable = false, updatable = false) + protected Instant createdAt; + + @Column(name = "updated_at", columnDefinition = "TIMESTAMP(6)", nullable = false) + protected Instant updatedAt; + + @PrePersist + void prePersist() { + var now = Instant.now(); + + createdAt = createdAt != null ? createdAt : now; + updatedAt = updatedAt != null ? updatedAt : now; + } + + @PreUpdate + void preUpdate() { + updatedAt = updatedAt != null ? updatedAt : Instant.now(); + } + +} diff --git a/src/main/java/net/teumteum/user/domain/ActivityArea.java b/src/main/java/net/teumteum/user/domain/ActivityArea.java new file mode 100644 index 00000000..c52938c7 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/ActivityArea.java @@ -0,0 +1,24 @@ +package net.teumteum.user.domain; + + +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embeddable; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class ActivityArea { + + @Column(name = "city") + private String city; + + @ElementCollection + private List street = new ArrayList<>(); +} diff --git a/src/main/java/net/teumteum/user/domain/Job.java b/src/main/java/net/teumteum/user/domain/Job.java new file mode 100644 index 00000000..d7ae245f --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/Job.java @@ -0,0 +1,27 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class Job { + + @Column(name = "job_name") + private String name; + + @Column(name = "certificated") + private boolean certificated; + + @Column(name = "job_class") + private String jobClass; + + @Column(name = "detail_job_class") + private String detailJobClass; + +} diff --git a/src/main/java/net/teumteum/user/domain/JobStatus.java b/src/main/java/net/teumteum/user/domain/JobStatus.java new file mode 100644 index 00000000..ea26730a --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/JobStatus.java @@ -0,0 +1,9 @@ +package net.teumteum.user.domain; + +public enum JobStatus { + + 직μž₯인, + 학생, + 취업쀀비생, + +} diff --git a/src/main/java/net/teumteum/user/domain/Oauth.java b/src/main/java/net/teumteum/user/domain/Oauth.java new file mode 100644 index 00000000..6305689d --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/Oauth.java @@ -0,0 +1,21 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class Oauth { + + @Column(name = "oauth_authenticate_info", unique = true) + private String oAuthAuthenticateInfo; + + @Column(name = "authenticated") + private String authenticated; + +} diff --git a/src/main/java/net/teumteum/user/domain/Terms.java b/src/main/java/net/teumteum/user/domain/Terms.java new file mode 100644 index 00000000..42b9971e --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/Terms.java @@ -0,0 +1,28 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.PrePersist; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class Terms { + + @Column(name = "terms_of_service", nullable = false) + private Boolean service; + + @Column(name = "privacy_policy", nullable = false) + private Boolean privacyPolicy; + + @PrePersist + private void assertTerms() { + Assert.isTrue(service, () -> "μ„œλΉ„μŠ€ 이용 약관은 항상 λ™μ˜ λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€."); + Assert.isTrue(privacyPolicy, () -> "κ°œμΈμ •λ³΄ 처리 방침은 항상 λ™μ˜ λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€."); + } +} diff --git a/src/main/java/net/teumteum/user/domain/User.java b/src/main/java/net/teumteum/user/domain/User.java new file mode 100644 index 00000000..60cad5a9 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/User.java @@ -0,0 +1,78 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.teumteum.core.entity.TimeBaseEntity; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.util.Assert; + +@Getter +@Entity(name = "users") +@NoArgsConstructor +@AllArgsConstructor +public class User extends TimeBaseEntity { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "name", length = 10) + private String name; + + @Column(name = "birth", length = 10) + @DateTimeFormat(pattern = "yyyy.MM.dd") + private String birth; + + @Column(name = "character_id") + private Long characterId; + + @Column(name = "manner_temperature") + private int mannerTemperature; + + @Embedded + private Oauth oauth; + + @Embedded + private ActivityArea activityArea; + + @Column(name = "mbti", length = 4) + private String mbti; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private JobStatus status; + + @Column(name = "goal", length = 50) + private String goal; + + @Embedded + private Job job; + + @ElementCollection + private List interests = new ArrayList<>(); + + @Embedded + private Terms terms; + + @PrePersist + private void assertField() { + assertName(); + } + + private void assertName() { + Assert.doesNotContain(name, " ", () -> "이름에 곡백이 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€. \"" + name + "\""); + Assert.isTrue(name.length() >= 2 && name.length() <= 10, () -> "이름은 2자 ~ 10자 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. \"" + name + "\""); + } + +} diff --git a/src/main/java/net/teumteum/user/domain/UserConnector.java b/src/main/java/net/teumteum/user/domain/UserConnector.java new file mode 100644 index 00000000..295ee545 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/UserConnector.java @@ -0,0 +1,8 @@ +package net.teumteum.user.domain; + +import java.util.Optional; + +public interface UserConnector { + + Optional findUserById(Long id); +} diff --git a/src/main/java/net/teumteum/user/domain/UserRepository.java b/src/main/java/net/teumteum/user/domain/UserRepository.java new file mode 100644 index 00000000..33e882c5 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/UserRepository.java @@ -0,0 +1,7 @@ +package net.teumteum.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + +} diff --git a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java new file mode 100644 index 00000000..93fca5b5 --- /dev/null +++ b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java @@ -0,0 +1,23 @@ +package net.teumteum.user.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import net.teumteum.user.domain.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserConnectorImpl implements UserConnector { + + private final UserRepository userRepository; + + @Override + public Optional findUserById(Long id) { + return userRepository.findById(id); + } + +} diff --git a/src/main/resources/db.migration/.gitkeep b/src/main/resources/db.migration/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/resources/db/migration/V1__create_users.sql b/src/main/resources/db/migration/V1__create_users.sql new file mode 100644 index 00000000..cbe3bac1 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_users.sql @@ -0,0 +1,34 @@ +create table if not exists users( + certificated boolean, + manner_temperature integer, + mbti varchar(4), + character_id bigint, + id bigint not null, + birth varchar(10), + name varchar(10), + goal varchar(50), + authenticated varchar(255), + oauth_authenticate_info varchar(255) unique, + city varchar(255), + detail_job_class varchar(255), + job_class varchar(255), + job_name varchar(255), + status enum('직μž₯인','학생','취업쀀비생'), + terms_of_service boolean not null, + privacy_policy boolean not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +); + +create table if not exists users_interests( + users_id bigint not null, + interests varchar(255), + foreign key (users_id) references users(id) +); + +create table if not exists users_street( + users_id bigint not null, + street varchar(255), + foreign key (users_id) references users(id) +); diff --git a/src/test/java/net/teumteum/user/domain/UserFixture.java b/src/test/java/net/teumteum/user/domain/UserFixture.java new file mode 100644 index 00000000..644fe66d --- /dev/null +++ b/src/test/java/net/teumteum/user/domain/UserFixture.java @@ -0,0 +1,69 @@ +package net.teumteum.user.domain; + +import java.util.List; +import lombok.Builder; + +public class UserFixture { + + public static User getUserWithId(Long id) { + return newUserByBuilder(UserBuilder.builder() + .id(id) + .build()); + } + + public static User getDefaultUser() { + return newUserByBuilder(UserBuilder.builder().build()); + } + + public static User newUserByBuilder(UserBuilder userBuilder) { + return new User( + userBuilder.id, + userBuilder.name, + userBuilder.birth, + userBuilder.characterId, + userBuilder.mannerTemperature, + userBuilder.oauth, + userBuilder.activityArea, + userBuilder.mbti, + userBuilder.status, + userBuilder.goal, + userBuilder.job, + userBuilder.interests, + userBuilder.terms + ); + } + + @Builder + public static class UserBuilder { + + @Builder.Default + private Long id = 0L; + @Builder.Default + private String name = "Jennifer"; + @Builder.Default + private String birth = "2000.02.05"; + @Builder.Default + private Long characterId = 1L; + @Builder.Default + private int mannerTemperature = 36; + @Builder.Default + private Oauth oauth = new Oauth("hello123@naver.com", "naver"); + @Builder.Default + private ActivityArea activityArea = new ActivityArea("μ„œμšΈ", List.of("강남", "ν™λŒ€")); + @Builder.Default + private String mbti = "ESFP"; + @Builder.Default + private JobStatus status = JobStatus.취업쀀비생; + @Builder.Default + private String goal = "μ·¨μ—…ν•˜κΈ°"; + @Builder.Default + private Job job = new Job("netflix", true, "developer", "backend"); + @Builder.Default + private List interests = List.of( + "game", "sleep", "Eating delicious food" + ); + @Builder.Default + private Terms terms = new Terms(true, true); + } + +} diff --git a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java new file mode 100644 index 00000000..19ad6119 --- /dev/null +++ b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java @@ -0,0 +1,66 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.EntityManager; +import java.util.Optional; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DataJpaTest +@DisplayName("UserRepository 클래슀의") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + @Nested + @DisplayName("save λ©”μ†Œλ“œλŠ”") + class Save_method { + + @Test + @DisplayName("μ˜¬λ°”λ₯Έ UserEntityκ°€ λ“€μ–΄μ˜€λ©΄, μœ μ € μ €μž₯에 μ„±κ³΅ν•œλ‹€.") + void Save_success_if_correct_user_entered() { + // given + var newUser = UserFixture.getDefaultUser(); + + // when + var result = Assertions.catchException(() -> userRepository.saveAndFlush(newUser)); + + // then + Assertions.assertThat(result).isNull(); + } + } + + @Nested + @DisplayName("findById λ©”μ†Œλ“œλŠ”") + class FindById_method { + + @Test + @DisplayName("μ €μž₯된 μœ μ €μ˜ id둜 μ‘°νšŒν•˜λ©΄, μœ μ €λ₯Ό λ°˜ν™˜ν•œλ‹€.") + void Find_success_if_exists_user_id_input() { + // given + var id = 1L; + var existsUser = UserFixture.getUserWithId(id); + + userRepository.saveAndFlush(existsUser); + entityManager.clear(); + + // when + var result = userRepository.findById(id); + + // then + Assertions.assertThat(result) + .isPresent() + .usingRecursiveComparison() + .ignoringFields("value.createdAt", "value.updatedAt") + .isEqualTo(Optional.of(existsUser)); + } + } + +} diff --git a/src/test/java/net/teumteum/user/service/UserConnectorTest.java b/src/test/java/net/teumteum/user/service/UserConnectorTest.java new file mode 100644 index 00000000..96bef652 --- /dev/null +++ b/src/test/java/net/teumteum/user/service/UserConnectorTest.java @@ -0,0 +1,59 @@ +package net.teumteum.user.service; + +import java.util.Optional; +import net.teumteum.user.domain.UserConnector; +import net.teumteum.user.domain.UserFixture; +import net.teumteum.user.domain.UserRepository; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@DisplayName("UserConnector 클래슀의") +@ContextConfiguration(classes = UserConnectorImpl.class) +class UserConnectorTest { + + private static final Long EXIST_USER_ID = 1L; + + @Autowired + private UserConnector userConnector; + @MockBean + private UserRepository userRepository; + + + @BeforeEach + void beforeEach() { + Mockito.when(userRepository.findById(Mockito.anyLong())).thenReturn(Optional.empty()); + Mockito.when(userRepository.findById(EXIST_USER_ID)).thenReturn(Optional.of(UserFixture.getDefaultUser())); + } + + @Nested + @DisplayName("findById λ©”μ†Œλ“œλŠ”") + class FindById_method { + + @Test + @DisplayName("μ‘΄μž¬ν•˜λŠ” user의 idκ°€ λ“€μ–΄μ˜€λ©΄, optional.userλ₯Ό λ°˜ν™˜ν•œλ‹€.") + void Return_optional_user_if_exists_user_id() { + // given + var expect = Optional.of(UserFixture.getDefaultUser()); + + // when + var result = userConnector.findUserById(EXIST_USER_ID); + + // then + Assertions.assertThat(result) + .isPresent() + .usingRecursiveComparison() + .isEqualTo(expect); + } + } + +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 00000000..1833301b --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,16 @@ +spring.datasource.driver-class-name = org.h2.Driver +spring.datasource.url = jdbc:h2:mem:test;MODE=MySQL;DATABASE_TO_LOWER=TRUE + +spring.jpa.hibernated.ddl-auto = validate +spring.jpa.database-platform = org.hibernate.dialect.H2Dialect + +spring.datasource.hikari.maximum-pool-size = 4 +spring.datasource.hikari.pool-name = H2_TEST_POOL + +### FOR DEBUGGING ### +logging.level.org.hibernate.SQL = debug +logging.level.org.hibernate.type.descriptor.sql = trace + +spring.jpa.properties.hibernate.format_sql = true +spring.jpa.properties.hibernate.highlight_sql = true +spring.jpa.properties.hibernate.use_sql_comments = true diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql new file mode 100644 index 00000000..cbe3bac1 --- /dev/null +++ b/src/test/resources/schema.sql @@ -0,0 +1,34 @@ +create table if not exists users( + certificated boolean, + manner_temperature integer, + mbti varchar(4), + character_id bigint, + id bigint not null, + birth varchar(10), + name varchar(10), + goal varchar(50), + authenticated varchar(255), + oauth_authenticate_info varchar(255) unique, + city varchar(255), + detail_job_class varchar(255), + job_class varchar(255), + job_name varchar(255), + status enum('직μž₯인','학생','취업쀀비생'), + terms_of_service boolean not null, + privacy_policy boolean not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) +); + +create table if not exists users_interests( + users_id bigint not null, + interests varchar(255), + foreign key (users_id) references users(id) +); + +create table if not exists users_street( + users_id bigint not null, + street varchar(255), + foreign key (users_id) references users(id) +);