diff --git a/AGENTS.md b/AGENTS.md index 38b3add..c16fa2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,13 +7,27 @@ - 생성자 주입을 사용하고 의존성을 명시적으로 드러낸다. - 전체 검증은 `./gradlew check`로 실행한다. 테스트만 다시 실행할 때는 `./gradlew test`를 사용한다. +## 도메인 엔티티 구현 + +- 도메인 모델과 JPA 엔티티를 별도 클래스로 나누지 않고 기능별 `domain` 패키지에서 하나의 클래스로 관리한다. +- JPA로 영속화하는 속성은 주 생성자에 `private var`로 선언하고 이름 앞에 `_`를 붙인다. JPA 애노테이션은 `@field:` 사용 지점 대상으로 해당 속성에 선언한다. +- `_id`는 생성자의 첫 번째 속성으로 배치하고 `Long? = null`을 기본값으로 사용한다. `@Id`, `@GeneratedValue(strategy = GenerationType.IDENTITY)`, + `@Column(name = "id")`을 명시한다. +- `_` 접두사가 데이터베이스 컬럼명에 반영되지 않도록 `@Column`, `@JoinColumn` 등에 실제 컬럼명을 명시한다. +- 영속 속성은 클래스 본문에서 접두사가 없는 읽기 전용 `val` getter로 공개한다. 공개 또는 보호된 setter를 만들지 않는다. +- 엔티티의 상태는 setter 대신 의도를 나타내는 도메인 함수로 변경한다. 유효성 검사와 비즈니스 규칙도 엔티티 내부에서 처리한다. +- 연관관계도 같은 방식으로 `private var` 필드에 매핑하고 읽기 전용 getter만 제공한다. 기본 조회 방식은 필요한 이유가 없다면 `FetchType.LAZY`를 사용한다. +- 생성 및 수정 시각은 `BaseEntity`를 상속해 관리하며 `TIMESTAMPTZ`에 대응하는 `Instant`를 사용한다. +- JPA가 엔티티를 생성할 수 있도록 공개 또는 보호된 인자 없는 생성자가 제공되는지 테스트한다. Kotlin JPA 플러그인이 생성한 생성자를 포함해 확인한다. +- 엔티티 변경 시 영속화, 읽기 전용 getter, 주요 도메인 함수, 공개 setter 부재를 테스트한다. + ## Git 작업 규칙 ### 브랜치 - 기능 개발 브랜치는 `feature/{작업-내용}` 형식으로 생성한다. - 설정, 빌드, 문서, 의존성 등 기능 외 작업 브랜치는 `chore/{작업-내용}` 형식으로 생성한다. -- `{작업-내용}`은 영문 소문자와 하이픈(`-`)을 사용해 짧고 구체적으로 작성한다. +- `{작업-내용}`은 영문 소문자와 하이픈 (`-`)을 사용해 짧고 구체적으로 작성한다. - 예: `feature/global-exception-handler`, `chore/github-actions-ci` ### 커밋 diff --git a/build.gradle b/build.gradle index 00e7f69..ae4650b 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { implementation 'org.jetbrains.kotlin:kotlin-reflect' runtimeOnly 'org.postgresql:postgresql' testAndDevelopmentOnly 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation platform('io.kotest:kotest-bom:6.1.5') testImplementation 'io.kotest:kotest-runner-junit5' diff --git a/src/main/kotlin/com/mogumogu/momogo/global/entity/BaseEntity.kt b/src/main/kotlin/com/mogumogu/momogo/global/entity/BaseEntity.kt index 2cd7fe4..67bcd26 100644 --- a/src/main/kotlin/com/mogumogu/momogo/global/entity/BaseEntity.kt +++ b/src/main/kotlin/com/mogumogu/momogo/global/entity/BaseEntity.kt @@ -3,20 +3,29 @@ package com.mogumogu.momogo.global.entity import jakarta.persistence.Column import jakarta.persistence.EntityListeners import jakarta.persistence.MappedSuperclass +import org.hibernate.annotations.ColumnDefault import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener -import java.time.LocalDateTime +import java.time.Instant @MappedSuperclass @EntityListeners(AuditingEntityListener::class) -abstract class BaseEntity { +abstract class BaseEntity( + @field:CreatedDate + @field:ColumnDefault("CURRENT_TIMESTAMP") + @field:Column(name = "created_at", nullable = false, updatable = false) + private var _createdAt: Instant? = null, - @CreatedDate - @Column(updatable = false, nullable = false) - lateinit var createdAt: LocalDateTime + @field:LastModifiedDate + @field:ColumnDefault("CURRENT_TIMESTAMP") + @field:Column(name = "updated_at", nullable = false) + private var _updatedAt: Instant? = null, +) { - @LastModifiedDate - @Column(nullable = false) - lateinit var updatedAt: LocalDateTime + val createdAt: Instant + get() = checkNotNull(_createdAt) { "생성 시각이 아직 설정되지 않았습니다." } + + val updatedAt: Instant + get() = checkNotNull(_updatedAt) { "수정 시각이 아직 설정되지 않았습니다." } } diff --git a/src/main/kotlin/com/mogumogu/momogo/group/application/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/group/application/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/group/application/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/group/domain/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/group/domain/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/group/domain/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/group/infra/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/group/infra/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/group/infra/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/group/presentation/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/group/presentation/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/group/presentation/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/photo/application/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/photo/application/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/photo/application/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/photo/domain/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/photo/domain/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/photo/domain/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/photo/infra/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/photo/infra/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/photo/infra/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/photo/presentation/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/photo/presentation/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/photo/presentation/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/user/application/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/user/application/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/application/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/main/kotlin/com/mogumogu/momogo/user/domain/LoginAccount.kt b/src/main/kotlin/com/mogumogu/momogo/user/domain/LoginAccount.kt new file mode 100644 index 0000000..bf68894 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/domain/LoginAccount.kt @@ -0,0 +1,70 @@ +package com.mogumogu.momogo.user.domain + +import com.mogumogu.momogo.global.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.ForeignKey +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "login_account") +class LoginAccount( + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "id") + private var _id: Long? = null, + + @field:ManyToOne(fetch = FetchType.LAZY, optional = false) + @field:JoinColumn( + name = "user_id", + nullable = false, + updatable = false, + foreignKey = ForeignKey(name = "fk_login_account_user"), + ) + private var _user: User, + + @field:Enumerated(EnumType.STRING) + @field:Column( + name = "provider", + nullable = false, + updatable = false, + length = 255, + columnDefinition = "VARCHAR(255)", + ) + private var _provider: LoginProvider, + + @field:Column(name = "provider_id", nullable = false, updatable = false, length = 255) + private var _providerId: String, +) : BaseEntity() { + + val id: Long? + get() = _id + + val user: User + get() = _user + + val provider: LoginProvider + get() = _provider + + val providerId: String + get() = _providerId + + init { + validateProviderId(_providerId) + } + + private companion object { + fun validateProviderId(providerId: String) { + require(providerId.isNotBlank()) { "로그인 제공자 회원 ID는 비어 있을 수 없습니다." } + require(providerId.length <= 255) { "로그인 제공자 회원 ID는 255자를 초과할 수 없습니다." } + } + } +} diff --git a/src/main/kotlin/com/mogumogu/momogo/user/domain/LoginProvider.kt b/src/main/kotlin/com/mogumogu/momogo/user/domain/LoginProvider.kt new file mode 100644 index 0000000..69938e7 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/domain/LoginProvider.kt @@ -0,0 +1,8 @@ +package com.mogumogu.momogo.user.domain + +enum class LoginProvider { + GUEST, + KAKAO, + NAVER, + APPLE, +} diff --git a/src/main/kotlin/com/mogumogu/momogo/user/domain/README.md b/src/main/kotlin/com/mogumogu/momogo/user/domain/README.md new file mode 100644 index 0000000..a5b08de --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/domain/README.md @@ -0,0 +1,13 @@ +# User Domain + +회원, 로그인 계정, 리프레시 토큰과 관련된 도메인 규칙을 관리하는 패키지다. +도메인 모델과 JPA 엔티티는 하나의 클래스로 구성하며, 엔티티가 자신의 상태와 비즈니스 규칙을 직접 관리한다. + +## 구성 + +- `User`: 회원 정보와 닉네임 변경 규칙 +- `LoginAccount`: 회원과 로그인 제공자 계정의 연결 +- `LoginProvider`: 지원하는 로그인 제공자 +- `RefreshToken`: 리프레시 토큰의 만료 및 폐기 상태 + +외부에서는 읽기 전용 getter로 상태를 조회하고, 상태 변경은 엔티티가 제공하는 도메인 함수를 통해서만 수행한다. diff --git a/src/main/kotlin/com/mogumogu/momogo/user/domain/RefreshToken.kt b/src/main/kotlin/com/mogumogu/momogo/user/domain/RefreshToken.kt new file mode 100644 index 0000000..c61698d --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/domain/RefreshToken.kt @@ -0,0 +1,85 @@ +package com.mogumogu.momogo.user.domain + +import com.mogumogu.momogo.global.entity.BaseEntity +import jakarta.persistence.* +import java.time.Instant + +@Entity +@Table( + name = "refresh_token", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_refresh_token_token_hash", + columnNames = ["token_hash"], + ), + ], +) +class RefreshToken( + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "id") + private var _id: Long? = null, + + @field:ManyToOne(fetch = FetchType.LAZY, optional = false) + @field:JoinColumn( + name = "user_id", + nullable = false, + updatable = false, + foreignKey = ForeignKey(name = "fk_refresh_token_user"), + ) + private var _user: User, + + @field:Column( + name = "token_hash", + nullable = false, + updatable = false, + length = 64, + columnDefinition = "CHAR(64)", + ) + private var _tokenHash: String, + + @field:Column(name = "expires_at", nullable = false, updatable = false) + private var _expiresAt: Instant, + + @field:Column(name = "revoked_at") + private var _revokedAt: Instant? = null, +) : BaseEntity() { + + val id: Long? + get() = _id + + val user: User + get() = _user + + val tokenHash: String + get() = _tokenHash + + val expiresAt: Instant + get() = _expiresAt + + val revokedAt: Instant? + get() = _revokedAt + + init { + validateTokenHash(_tokenHash) + } + + fun revoke(at: Instant) { + if (_revokedAt == null) { + _revokedAt = at + } + } + + fun isActive(at: Instant): Boolean = + _revokedAt == null && _expiresAt.isAfter(at) + + private companion object { + private val SHA_256_PATTERN = Regex("^[0-9a-fA-F]{64}$") + + fun validateTokenHash(tokenHash: String) { + require(SHA_256_PATTERN.matches(tokenHash)) { + "리프레시 토큰 해시는 64자리 16진수여야 합니다." + } + } + } +} diff --git a/src/main/kotlin/com/mogumogu/momogo/user/domain/User.kt b/src/main/kotlin/com/mogumogu/momogo/user/domain/User.kt new file mode 100644 index 0000000..9c5a216 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/domain/User.kt @@ -0,0 +1,44 @@ +package com.mogumogu.momogo.user.domain + +import com.mogumogu.momogo.global.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "\"user\"") +class User( + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "id") + private var _id: Long? = null, + + @field:Column(name = "nickname", nullable = false, length = 255) + private var _nickname: String, +) : BaseEntity() { + + val id: Long? + get() = _id + + val nickname: String + get() = _nickname + + init { + validateNickname(_nickname) + } + + fun updateNickname(nickname: String) { + validateNickname(nickname) + _nickname = nickname + } + + private companion object { + fun validateNickname(nickname: String) { + require(nickname.isNotBlank()) { "닉네임은 비어 있을 수 없습니다." } + require(nickname.length <= 255) { "닉네임은 255자를 초과할 수 없습니다." } + } + } +} diff --git a/src/main/kotlin/com/mogumogu/momogo/user/infra/LoginAccountRepository.kt b/src/main/kotlin/com/mogumogu/momogo/user/infra/LoginAccountRepository.kt new file mode 100644 index 0000000..89f7608 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/infra/LoginAccountRepository.kt @@ -0,0 +1,6 @@ +package com.mogumogu.momogo.user.infra + +import com.mogumogu.momogo.user.domain.LoginAccount +import org.springframework.data.jpa.repository.JpaRepository + +interface LoginAccountRepository : JpaRepository diff --git a/src/main/kotlin/com/mogumogu/momogo/user/infra/RefreshTokenRepository.kt b/src/main/kotlin/com/mogumogu/momogo/user/infra/RefreshTokenRepository.kt new file mode 100644 index 0000000..17cb9ef --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/infra/RefreshTokenRepository.kt @@ -0,0 +1,6 @@ +package com.mogumogu.momogo.user.infra + +import com.mogumogu.momogo.user.domain.RefreshToken +import org.springframework.data.jpa.repository.JpaRepository + +interface RefreshTokenRepository : JpaRepository diff --git a/src/main/kotlin/com/mogumogu/momogo/user/infra/UserRepository.kt b/src/main/kotlin/com/mogumogu/momogo/user/infra/UserRepository.kt new file mode 100644 index 0000000..f1c1b17 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/infra/UserRepository.kt @@ -0,0 +1,6 @@ +package com.mogumogu.momogo.user.infra + +import com.mogumogu.momogo.user.domain.User +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository diff --git a/src/main/kotlin/com/mogumogu/momogo/user/presentation/.gitkeep b/src/main/kotlin/com/mogumogu/momogo/user/presentation/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/kotlin/com/mogumogu/momogo/user/presentation/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/test/kotlin/com/mogumogu/momogo/user/domain/LoginAccountTest.kt b/src/test/kotlin/com/mogumogu/momogo/user/domain/LoginAccountTest.kt new file mode 100644 index 0000000..2b44a46 --- /dev/null +++ b/src/test/kotlin/com/mogumogu/momogo/user/domain/LoginAccountTest.kt @@ -0,0 +1,57 @@ +package com.mogumogu.momogo.user.domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe + +class LoginAccountTest : BehaviorSpec({ + + given("유효한 로그인 계정 정보가 있으면") { + `when`("로그인 계정을 생성할 때") { + then("회원과 로그인 제공자 정보를 조회할 수 있다") { + val user = User(_nickname = "모고") + val loginAccount = LoginAccount( + _id = 1L, + _user = user, + _provider = LoginProvider.APPLE, + _providerId = "apple-user-1", + ) + + loginAccount.id shouldBe 1L + loginAccount.user shouldBe user + loginAccount.provider shouldBe LoginProvider.APPLE + loginAccount.providerId shouldBe "apple-user-1" + } + } + } + + given("유효하지 않은 로그인 제공자 회원 ID가 있으면") { + val user = User(_nickname = "모고") + + `when`("빈 값으로 로그인 계정을 생성할 때") { + then("생성을 거부한다") { + listOf("", " ", "\t").forEach { providerId -> + shouldThrow { + LoginAccount( + _user = user, + _provider = LoginProvider.GUEST, + _providerId = providerId, + ) + }.message shouldBe "로그인 제공자 회원 ID는 비어 있을 수 없습니다." + } + } + } + + `when`("255자를 초과한 값으로 로그인 계정을 생성할 때") { + then("생성을 거부한다") { + shouldThrow { + LoginAccount( + _user = user, + _provider = LoginProvider.KAKAO, + _providerId = "a".repeat(256), + ) + }.message shouldBe "로그인 제공자 회원 ID는 255자를 초과할 수 없습니다." + } + } + } +}) diff --git a/src/test/kotlin/com/mogumogu/momogo/user/domain/LoginProviderTest.kt b/src/test/kotlin/com/mogumogu/momogo/user/domain/LoginProviderTest.kt new file mode 100644 index 0000000..aeb9f59 --- /dev/null +++ b/src/test/kotlin/com/mogumogu/momogo/user/domain/LoginProviderTest.kt @@ -0,0 +1,18 @@ +package com.mogumogu.momogo.user.domain + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.collections.shouldContainExactly + +class LoginProviderTest : BehaviorSpec({ + + given("지원하는 로그인 제공자를 확인하면") { + then("게스트와 카카오, 네이버, 애플을 제공한다") { + LoginProvider.entries shouldContainExactly listOf( + LoginProvider.GUEST, + LoginProvider.KAKAO, + LoginProvider.NAVER, + LoginProvider.APPLE, + ) + } + } +}) diff --git a/src/test/kotlin/com/mogumogu/momogo/user/domain/RefreshTokenTest.kt b/src/test/kotlin/com/mogumogu/momogo/user/domain/RefreshTokenTest.kt new file mode 100644 index 0000000..4976bf1 --- /dev/null +++ b/src/test/kotlin/com/mogumogu/momogo/user/domain/RefreshTokenTest.kt @@ -0,0 +1,105 @@ +package com.mogumogu.momogo.user.domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import java.time.Instant + +class RefreshTokenTest : BehaviorSpec({ + + val expiresAt = Instant.parse("2030-01-01T00:00:00Z") + + given("유효한 리프레시 토큰 정보가 있으면") { + `when`("리프레시 토큰을 생성할 때") { + then("토큰 정보를 조회할 수 있다") { + val user = User(_nickname = "모고") + val tokenHash = "aB".repeat(32) + val refreshToken = RefreshToken( + _id = 1L, + _user = user, + _tokenHash = tokenHash, + _expiresAt = expiresAt, + ) + + refreshToken.id shouldBe 1L + refreshToken.user shouldBe user + refreshToken.tokenHash shouldBe tokenHash + refreshToken.expiresAt shouldBe expiresAt + refreshToken.revokedAt shouldBe null + } + } + } + + given("폐기되지 않은 리프레시 토큰이 있으면") { + `when`("만료 시각 전에 활성 상태를 확인할 때") { + then("활성 상태다") { + val refreshToken = createRefreshToken(expiresAt) + + refreshToken.isActive(expiresAt.minusNanos(1)) shouldBe true + } + } + + `when`("만료 시각 또는 그 이후에 활성 상태를 확인할 때") { + then("비활성 상태다") { + val refreshToken = createRefreshToken(expiresAt) + + refreshToken.isActive(expiresAt) shouldBe false + refreshToken.isActive(expiresAt.plusNanos(1)) shouldBe false + } + } + + `when`("토큰을 폐기할 때") { + then("폐기 시각을 기록하고 비활성 상태가 된다") { + val refreshToken = createRefreshToken(expiresAt) + val revokedAt = Instant.parse("2029-01-01T00:00:00Z") + + refreshToken.revoke(revokedAt) + + refreshToken.revokedAt shouldBe revokedAt + refreshToken.isActive(revokedAt) shouldBe false + } + } + } + + given("이미 폐기된 리프레시 토큰이 있으면") { + `when`("다시 폐기할 때") { + then("최초 폐기 시각을 유지한다") { + val refreshToken = createRefreshToken(expiresAt) + val firstRevokedAt = Instant.parse("2029-01-01T00:00:00Z") + + refreshToken.revoke(firstRevokedAt) + refreshToken.revoke(firstRevokedAt.plusSeconds(1)) + + refreshToken.revokedAt shouldBe firstRevokedAt + } + } + } + + given("SHA-256 형식이 아닌 토큰 해시가 있으면") { + `when`("리프레시 토큰을 생성할 때") { + then("생성을 거부한다") { + listOf( + "", + "a".repeat(63), + "a".repeat(65), + "g".repeat(64), + ).forEach { tokenHash -> + shouldThrow { + RefreshToken( + _user = User(_nickname = "모고"), + _tokenHash = tokenHash, + _expiresAt = expiresAt, + ) + }.message shouldBe "리프레시 토큰 해시는 64자리 16진수여야 합니다." + } + } + } + } +}) + +private fun createRefreshToken(expiresAt: Instant): RefreshToken = + RefreshToken( + _user = User(_nickname = "모고"), + _tokenHash = "a".repeat(64), + _expiresAt = expiresAt, + ) diff --git a/src/test/kotlin/com/mogumogu/momogo/user/domain/UserEntityTest.kt b/src/test/kotlin/com/mogumogu/momogo/user/domain/UserEntityTest.kt new file mode 100644 index 0000000..27432d8 --- /dev/null +++ b/src/test/kotlin/com/mogumogu/momogo/user/domain/UserEntityTest.kt @@ -0,0 +1,154 @@ +package com.mogumogu.momogo.user.domain + +import com.mogumogu.momogo.global.config.JpaConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.extensions.ApplyExtension +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.engine.concurrency.TestExecutionMode +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.comparables.shouldBeGreaterThanOrEqualTo +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.hibernate.exception.ConstraintViolationException +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import java.time.Instant + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(JpaConfig::class) +@ApplyExtension(SpringExtension::class) +class UserEntityTest( + private val entityManager: EntityManager, +) : BehaviorSpec({ + testExecutionMode = TestExecutionMode.Sequential + + given("회원과 로그인 계정, 리프레시 토큰이 있으면") { + `when`("하나의 도메인 모델을 JPA 엔티티로 저장할 때") { + then("연관관계와 감사 시각을 포함해 다시 조회할 수 있다") { + val user = User(_nickname = "모고") + val loginAccount = LoginAccount( + _user = user, + _provider = LoginProvider.APPLE, + _providerId = "apple-user-1", + ) + val expiresAt = Instant.parse("2030-01-01T00:00:00Z") + val refreshToken = RefreshToken( + _user = user, + _tokenHash = "a".repeat(64), + _expiresAt = expiresAt, + ) + + entityManager.persist(user) + entityManager.persist(loginAccount) + entityManager.persist(refreshToken) + entityManager.flush() + + val userId = requireNotNull(user.id) + val loginAccountId = requireNotNull(loginAccount.id) + val refreshTokenId = requireNotNull(refreshToken.id) + entityManager.clear() + + val savedUser = entityManager.find(User::class.java, userId) + val savedLoginAccount = entityManager.find(LoginAccount::class.java, loginAccountId) + val savedRefreshToken = entityManager.find(RefreshToken::class.java, refreshTokenId) + + savedUser.nickname shouldBe "모고" + savedUser.updatedAt shouldBeGreaterThanOrEqualTo savedUser.createdAt + savedLoginAccount.user.id shouldBe userId + savedLoginAccount.provider shouldBe LoginProvider.APPLE + savedLoginAccount.providerId shouldBe "apple-user-1" + savedLoginAccount.updatedAt shouldBeGreaterThanOrEqualTo savedLoginAccount.createdAt + savedRefreshToken.user.id shouldBe userId + savedRefreshToken.tokenHash shouldBe "a".repeat(64) + savedRefreshToken.expiresAt shouldBe expiresAt + savedRefreshToken.revokedAt shouldBe null + savedRefreshToken.updatedAt shouldBeGreaterThanOrEqualTo savedRefreshToken.createdAt + } + } + } + + given("저장된 회원과 리프레시 토큰이 있으면") { + `when`("닉네임을 바꾸고 토큰을 무효화할 때") { + then("도메인 변경이 데이터베이스에 반영된다") { + val user = User(_nickname = "변경 전") + val refreshToken = RefreshToken( + _user = user, + _tokenHash = "b".repeat(64), + _expiresAt = Instant.parse("2030-01-01T00:00:00Z"), + ) + entityManager.persist(user) + entityManager.persist(refreshToken) + entityManager.flush() + + val userId = requireNotNull(user.id) + val refreshTokenId = requireNotNull(refreshToken.id) + val revokedAt = Instant.parse("2029-01-01T00:00:00Z") + + user.updateNickname("변경 후") + refreshToken.revoke(revokedAt) + entityManager.flush() + entityManager.clear() + + val savedUser = entityManager.find(User::class.java, userId) + val savedRefreshToken = entityManager.find(RefreshToken::class.java, refreshTokenId) + + savedUser.nickname shouldBe "변경 후" + savedRefreshToken.revokedAt shouldBe revokedAt + savedRefreshToken.isActive(revokedAt) shouldBe false + } + } + } + + given("이미 저장된 리프레시 토큰 해시가 있으면") { + `when`("같은 해시를 다시 저장할 때") { + then("고유 제약으로 저장을 거부한다") { + val user = User(_nickname = "모고") + val tokenHash = "c".repeat(64) + val expiresAt = Instant.parse("2030-01-01T00:00:00Z") + entityManager.persist(user) + entityManager.persist( + RefreshToken( + _user = user, + _tokenHash = tokenHash, + _expiresAt = expiresAt, + ), + ) + entityManager.flush() + + shouldThrow { + entityManager.persist( + RefreshToken( + _user = user, + _tokenHash = tokenHash, + _expiresAt = expiresAt, + ), + ) + entityManager.flush() + } + } + } + } + + given("엔티티의 영속 필드가 캡슐화되어 있으면") { + then("공개 setter 없이 JPA 기본 생성자와 getter만 제공한다") { + val entityClasses = listOf( + User::class.java, + LoginAccount::class.java, + RefreshToken::class.java, + ) + + entityClasses.forEach { entityClass -> + entityClass.constructors.any { constructor -> + constructor.parameterCount == 0 + } shouldBe true + entityClass.methods.none { method -> + method.name.startsWith("set") + } shouldBe true + } + } + } +}) diff --git a/src/test/kotlin/com/mogumogu/momogo/user/domain/UserTest.kt b/src/test/kotlin/com/mogumogu/momogo/user/domain/UserTest.kt new file mode 100644 index 0000000..cce7b4a --- /dev/null +++ b/src/test/kotlin/com/mogumogu/momogo/user/domain/UserTest.kt @@ -0,0 +1,64 @@ +package com.mogumogu.momogo.user.domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe + +class UserTest : BehaviorSpec({ + + given("유효한 회원 정보가 있으면") { + `when`("회원을 생성할 때") { + then("식별자와 닉네임을 조회할 수 있다") { + val user = User( + _id = 1L, + _nickname = "모고", + ) + + user.id shouldBe 1L + user.nickname shouldBe "모고" + } + } + + `when`("닉네임을 변경할 때") { + then("변경된 닉네임을 조회할 수 있다") { + val user = User(_nickname = "변경 전") + + user.updateNickname("변경 후") + + user.nickname shouldBe "변경 후" + } + } + } + + given("유효하지 않은 닉네임이 있으면") { + `when`("빈 닉네임으로 회원을 생성할 때") { + then("생성을 거부한다") { + listOf("", " ", "\t").forEach { nickname -> + shouldThrow { + User(_nickname = nickname) + }.message shouldBe "닉네임은 비어 있을 수 없습니다." + } + } + } + + `when`("255자를 초과한 닉네임으로 회원을 생성할 때") { + then("생성을 거부한다") { + shouldThrow { + User(_nickname = "가".repeat(256)) + }.message shouldBe "닉네임은 255자를 초과할 수 없습니다." + } + } + + `when`("유효하지 않은 닉네임으로 변경할 때") { + then("기존 닉네임을 유지한다") { + val user = User(_nickname = "기존 닉네임") + + shouldThrow { + user.updateNickname("") + } + + user.nickname shouldBe "기존 닉네임" + } + } + } +}) diff --git a/src/test/kotlin/com/mogumogu/momogo/user/infra/UserRepositoryTest.kt b/src/test/kotlin/com/mogumogu/momogo/user/infra/UserRepositoryTest.kt new file mode 100644 index 0000000..a174ddb --- /dev/null +++ b/src/test/kotlin/com/mogumogu/momogo/user/infra/UserRepositoryTest.kt @@ -0,0 +1,66 @@ +package com.mogumogu.momogo.user.infra + +import com.mogumogu.momogo.global.config.JpaConfig +import com.mogumogu.momogo.user.domain.LoginAccount +import com.mogumogu.momogo.user.domain.LoginProvider +import com.mogumogu.momogo.user.domain.RefreshToken +import com.mogumogu.momogo.user.domain.User +import io.kotest.core.extensions.ApplyExtension +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.extensions.spring.SpringExtension +import io.kotest.matchers.shouldBe +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import java.time.Instant + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(JpaConfig::class) +@ApplyExtension(SpringExtension::class) +class UserRepositoryTest( + private val userRepository: UserRepository, + private val loginAccountRepository: LoginAccountRepository, + private val refreshTokenRepository: RefreshTokenRepository, +) : BehaviorSpec({ + + given("회원과 로그인 계정, 리프레시 토큰이 있으면") { + `when`("각 Repository로 저장하고 조회할 때") { + then("기본 JPA CRUD를 사용할 수 있다") { + val user = userRepository.saveAndFlush( + User(_nickname = "모고"), + ) + val userId = requireNotNull(user.id) + + val loginAccount = loginAccountRepository.saveAndFlush( + LoginAccount( + _user = user, + _provider = LoginProvider.KAKAO, + _providerId = "kakao-user-1", + ), + ) + val loginAccountId = requireNotNull(loginAccount.id) + + val refreshToken = refreshTokenRepository.saveAndFlush( + RefreshToken( + _user = user, + _tokenHash = "d".repeat(64), + _expiresAt = Instant.parse("2030-01-01T00:00:00Z"), + ), + ) + val refreshTokenId = requireNotNull(refreshToken.id) + + userRepository.findById(userId).orElseThrow().nickname shouldBe "모고" + loginAccountRepository.findById(loginAccountId).orElseThrow().provider shouldBe LoginProvider.KAKAO + refreshTokenRepository.findById(refreshTokenId).orElseThrow().tokenHash shouldBe "d".repeat(64) + + refreshTokenRepository.deleteById(refreshTokenId) + refreshTokenRepository.flush() + + refreshTokenRepository.existsById(refreshTokenId) shouldBe false + } + } + } +})