diff --git a/build.gradle.kts b/build.gradle.kts index e1f398c..511956d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,22 @@ ktlint { version = properties["ktlintVersion"] as String } -tasks.withType { - useJUnitPlatform() +tasks.test { + useJUnitPlatform { + excludeTags("network") + } +} + +// 외부 상태에 결과가 달려 있어 기본 test에서 뺀다. 네트워크나 GitHub이 흔들리면 우리 잘못 없이 빨간불이 된다. +// 캐시도 끈다 — 지난번 통과했다고 건너뛰면 정작 확인하려던 것을 확인하지 않는다. +tasks.register("networkTest") { + testClassesDirs = + sourceSets.test + .get() + .output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + useJUnitPlatform { + includeTags("network") + } + outputs.upToDateWhen { false } } diff --git a/src/main/kotlin/com/nexters/gitit/application/RegisterProject.kt b/src/main/kotlin/com/nexters/gitit/application/RegisterProject.kt new file mode 100644 index 0000000..5c0e2d5 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/application/RegisterProject.kt @@ -0,0 +1,78 @@ +package com.nexters.gitit.application + +import com.nexters.gitit.domain.exception.BaseException +import com.nexters.gitit.domain.exception.ErrorCode +import com.nexters.gitit.domain.project.Project +import com.nexters.gitit.domain.project.ProjectRepository +import com.nexters.gitit.domain.project.QuizLevel +import com.nexters.gitit.domain.quizrepo.GithubRepositoryResolver +import com.nexters.gitit.domain.quizrepo.QuizGenerationRequested +import com.nexters.gitit.domain.quizrepo.QuizRepo +import com.nexters.gitit.domain.quizrepo.QuizRepoRepository +import com.nexters.gitit.domain.quizrepo.QuizRepoStatus +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service + +@Service +class RegisterProject( + private val quizRepoRepository: QuizRepoRepository, + private val projectRepository: ProjectRepository, + private val githubRepositoryResolver: GithubRepositoryResolver, + private val eventPublisher: ApplicationEventPublisher, +) { + /** + * GitHub 저장소를 받아 회원의 프로젝트로 등록합니다. 다른 회원이 이미 등록해 둔 저장소면 문제 세트를 새로 + * 만들지 않고 그대로 함께 씁니다. + * + * 문제 생성을 기다리지 않고 이벤트로 넘기므로, 갓 등록한 저장소의 결과 상태는 아직 완료가 아닙니다. + * + * 등록할 수 없는 저장소는 결과가 아니라 예외로 알립니다. GitHub에 없으면 도큐먼트를 남기지 않는데, 식별자를 + * 얻지 못해 유니크 키를 만들 수 없고 오타 URL마다 레코드가 쌓이기 때문입니다. 문제를 낼 수 없다고 이미 + * 판정된 저장소는 그때 기록해 둔 사유를 그대로 던집니다. + */ + operator fun invoke(command: Command): Result { + val githubRepoId = + githubRepositoryResolver.resolve(command.githubRepoUrl) + ?: throw BaseException(ErrorCode.INVALID_INPUT, "유효하지 않은 GitHub 저장소입니다") + + val quizRepo = registerQuizRepo(githubRepoId, command.githubRepoUrl) + if (quizRepo.status == QuizRepoStatus.REJECTED) { + // reject()가 상태와 사유를 함께 세팅하므로 사유가 빌 수 없지만, 타입이 nullable이라 기본값을 둔다. + throw BaseException(quizRepo.rejectedReason ?: ErrorCode.INVALID_INPUT) + } + + val project = projectRepository.saveIfAbsent(Project(command.memberId, quizRepo.id, command.quizLevel)) + + return Result(project, quizRepo.status) + } + + /** + * 넘긴 객체가 그대로 돌아왔을 때만 이번 요청이 저장소를 만든 것이고, 그때만 문제 생성을 겁니다. + * 이미 있던 저장소까지 이벤트를 내면 같은 저장소에 수 분짜리 생성 작업이 중복으로 돕니다. + */ + private fun registerQuizRepo( + githubRepoId: String, + githubRepoUrl: String, + ): QuizRepo { + val requested = QuizRepo(githubRepoId = githubRepoId, githubRepoUrl = githubRepoUrl) + val quizRepo = quizRepoRepository.saveIfAbsent(requested) + if (quizRepo.id == requested.id) { + eventPublisher.publishEvent(QuizGenerationRequested(quizRepo.id)) + } + + return quizRepo + } + + data class Command( + val memberId: String, + val githubRepoUrl: String, + val quizLevel: QuizLevel, + ) + + data class Result( + val projectId: String, + val status: QuizRepoStatus, + ) { + constructor(project: Project, status: QuizRepoStatus) : this(project.id, status) + } +} diff --git a/src/main/kotlin/com/nexters/gitit/domain/project/Project.kt b/src/main/kotlin/com/nexters/gitit/domain/project/Project.kt new file mode 100644 index 0000000..02b3546 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/project/Project.kt @@ -0,0 +1,27 @@ +package com.nexters.gitit.domain.project + +import com.nexters.gitit.domain.common.BaseEntity +import org.springframework.data.mongodb.core.index.CompoundIndex +import org.springframework.data.mongodb.core.index.Indexed +import org.springframework.data.mongodb.core.mapping.Document + +/** + * 회원 한 명이 문제 저장소 하나를 학습하는 단위. 사용자에게 보이는 "내 프로젝트"가 이것입니다. + * + * 문제는 `QuizRepo`에 공용으로 모아두고 여기서는 그 참조와 회원별 난이도만 갖습니다. 회원이나 저장소 어느 + * 한쪽에 배열로 품지 않고 따로 둔 이유는 조회 방향이 둘 다 필요해서입니다 — "내 프로젝트 목록"은 [memberId]로 + * 찾고, 문제 생성이 끝났을 때 알릴 대상은 [quizRepoId]로 찾습니다. 두 방향 모두 인덱스로 풀리게 걸어 둡니다. + */ +@Document(collection = "projects") +@CompoundIndex( + name = "uk_member_quiz_repo", + def = "{'memberId': 1, 'quizRepoId': 1}", + unique = true, + partialFilter = "{'deletedAt': null}", +) +class Project( + val memberId: String, + @Indexed(name = "idx_quiz_repo_id") + val quizRepoId: String, + val quizLevel: QuizLevel, +) : BaseEntity() diff --git a/src/main/kotlin/com/nexters/gitit/domain/project/ProjectRepository.kt b/src/main/kotlin/com/nexters/gitit/domain/project/ProjectRepository.kt new file mode 100644 index 0000000..6fec361 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/project/ProjectRepository.kt @@ -0,0 +1,10 @@ +package com.nexters.gitit.domain.project + +interface ProjectRepository { + /** + * 그 회원이 이미 학습 중인 저장소면 기존 프로젝트를, 아니면 새로 저장한 것을 돌려줍니다. + * + * 이미 있을 때 난이도를 덮어쓰지 않는 것이 이 메서드의 계약입니다. 난이도 변경은 등록과 구분되는 별도 행위입니다. + */ + fun saveIfAbsent(project: Project): Project +} diff --git a/src/main/kotlin/com/nexters/gitit/domain/project/QuizLevel.kt b/src/main/kotlin/com/nexters/gitit/domain/project/QuizLevel.kt new file mode 100644 index 0000000..d448d20 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/project/QuizLevel.kt @@ -0,0 +1,13 @@ +package com.nexters.gitit.domain.project + +/** + * 회원이 고른 문제 난이도. + * + * 직급(주니어·시니어)이 아니라 프로젝트를 얼마나 깊이 파고들지로만 나눕니다. + * [L1] 오리엔테이션급 · [L2] 동작 이해 · [L3] 설계 이해. + */ +enum class QuizLevel { + L1, + L2, + L3, +} diff --git a/src/main/kotlin/com/nexters/gitit/domain/quizrepo/GithubRepositoryResolver.kt b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/GithubRepositoryResolver.kt new file mode 100644 index 0000000..1f6d34a --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/GithubRepositoryResolver.kt @@ -0,0 +1,13 @@ +package com.nexters.gitit.domain.quizrepo + +interface GithubRepositoryResolver { + /** + * URL을 파싱하고 GitHub에 실재하는지 확인해 그 저장소의 GitHub id를 반환합니다. 둘을 한 호출로 묶은 것은 + * id를 얻었다는 사실 자체가 곧 등록 가능하다는 뜻이 되게 하려는 것입니다. 파싱 실패든 GitHub에 없음이든 + * 구분 없이 null이라, 호출부는 "파싱은 됐는데 없는 저장소" 같은 중간 상태를 다루지 않습니다. + * + * `owner/name`이 아니라 id인 것은 리네임·소유자 이전을 견디기 위해서입니다. 이름을 키로 잡으면 리네임 + * 한 번에 같은 저장소의 문제 세트가 둘로 갈라집니다. + */ + fun resolve(githubRepoUrl: String): String? +} diff --git a/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizGenerationRequested.kt b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizGenerationRequested.kt new file mode 100644 index 0000000..5e682a0 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizGenerationRequested.kt @@ -0,0 +1,11 @@ +package com.nexters.gitit.domain.quizrepo + +/** + * 문제를 채워 넣어야 할 저장소가 새로 생겼음을 알립니다. + * + * 생성은 수 분이 걸려 요청 스레드에서 끝낼 수 없으므로, 등록은 여기서 끊고 실제 생성은 이 이벤트를 받는 쪽이 맡습니다. + * 스냅숏 대신 식별자만 싣는 이유는 수신 시점에 상태가 이미 달라져 있을 수 있어, 받는 쪽이 다시 읽는 편이 안전해서입니다. + */ +data class QuizGenerationRequested( + val quizRepoId: String, +) diff --git a/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepo.kt b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepo.kt new file mode 100644 index 0000000..4352722 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepo.kt @@ -0,0 +1,41 @@ +package com.nexters.gitit.domain.quizrepo + +import com.nexters.gitit.domain.common.BaseEntity +import com.nexters.gitit.domain.exception.ErrorCode +import org.springframework.data.mongodb.core.index.CompoundIndex +import org.springframework.data.mongodb.core.mapping.Document + +/** + * GitHub 저장소 하나에서 뽑아낸 문제를 모아두는 곳. + * + * 회원이 아니라 저장소가 주인공인 공용 애그리거트라, 같은 저장소를 여러 회원이 등록해도 문제 세트는 하나만 + * 만들고 나눠 씁니다. 누가 이걸 학습하는지와 회원별 난이도는 `Project`가 들고 있어, 회원 수와 무관하게 + * 이 도큐먼트의 크기가 고정됩니다. + */ +@Document(collection = "quiz_repos") +@CompoundIndex( + name = "uk_github_repo_id", + def = "{'githubRepoId': 1}", + unique = true, + partialFilter = "{'deletedAt': null}", +) +class QuizRepo( + val githubRepoId: String, + val githubRepoUrl: String, +) : BaseEntity() { + // 생성 파이프라인이 최종 상태를 결정하므로 등록 시점에는 항상 시작 상태다. + var status: QuizRepoStatus = QuizRepoStatus.READY + private set + + // 전용 enum을 만들지 않고 ErrorCode를 재사용하는 것은, 어차피 클라이언트에게 같은 코드로 알려줘야 해서 목록이 두 벌이 되기 때문이다. + var rejectedReason: ErrorCode? = null + private set + + /** + * 상태와 사유를 함께 바꿔 사유 없는 [QuizRepoStatus.REJECTED]가 생기지 않게 합니다. + */ + fun reject(reason: ErrorCode) { + status = QuizRepoStatus.REJECTED + rejectedReason = reason + } +} diff --git a/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoRepository.kt b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoRepository.kt new file mode 100644 index 0000000..386de4b --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoRepository.kt @@ -0,0 +1,11 @@ +package com.nexters.gitit.domain.quizrepo + +interface QuizRepoRepository { + /** + * 같은 저장소가 이미 있으면 그것을, 없으면 새로 저장한 것을 돌려줍니다. + * + * 여러 요청이 같은 저장소를 동시에 등록해도 하나만 남습니다. 그래서 호출부는 넘긴 객체가 그대로 돌아왔는지로 + * 자기가 만든 것인지 알 수 있습니다. + */ + fun saveIfAbsent(quizRepo: QuizRepo): QuizRepo +} diff --git a/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoStatus.kt b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoStatus.kt new file mode 100644 index 0000000..1c80381 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoStatus.kt @@ -0,0 +1,15 @@ +package com.nexters.gitit.domain.quizrepo + +/** + * 문제 저장소의 문제 생성이 어디까지 왔는지. 등록 직후가 [READY], 문제가 다 만들어지면 [COMPLETED], + * 문제를 낼 수 없다고 판정되면 [REJECTED]입니다. + * + * 값이 셋뿐인 것은 생성 중간 단계(문서 분석·앵커·생성 등)에 아직 이름을 붙이지 않았기 때문입니다. + * 파이프라인이 붙으면 [READY]와 [COMPLETED] 사이에 값이 여럿 끼어들 예정이라, 상태를 판정할 때는 + * 해당하는 값을 나열하기보다 "무엇이 아닌가"로 거르는 쪽이 새 값에 안 깨집니다. + */ +enum class QuizRepoStatus { + READY, + REJECTED, + COMPLETED, +} diff --git a/src/main/kotlin/com/nexters/gitit/infrastructure/github/GithubApiRepositoryResolver.kt b/src/main/kotlin/com/nexters/gitit/infrastructure/github/GithubApiRepositoryResolver.kt new file mode 100644 index 0000000..77dce7f --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/infrastructure/github/GithubApiRepositoryResolver.kt @@ -0,0 +1,55 @@ +package com.nexters.gitit.infrastructure.github + +import com.nexters.gitit.domain.quizrepo.GithubRepositoryResolver +import org.springframework.stereotype.Component +import org.springframework.web.client.HttpClientErrorException +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +@Component +class GithubApiRepositoryResolver( + private val githubRestClient: RestClient, +) : GithubRepositoryResolver { + /** + * 404만 null로 접고 나머지는 그대로 던집니다. 5xx·네트워크 오류까지 "없음"으로 묻으면 멀쩡한 저장소가 + * 등록을 거부당하고, 사용자는 다시 시도하면 되는 상황인 줄 모릅니다. + */ + override fun resolve(githubRepoUrl: String): String? { + val (owner, name) = parseOwnerAndName(githubRepoUrl) ?: return null + + return try { + githubRestClient + .get() + .uri("/repos/{owner}/{name}", owner, name) + .retrieve() + .body() + ?.id + ?.toString() + } catch (_: HttpClientErrorException.NotFound) { + // 비공개 저장소도 404다. 토큰 없이 못 읽는다는 점에서 없는 것과 결과가 같아 구분하지 않는다. + null + } + } + + /** 이름에 점이 들어가는 경우(`socket.io`)와 `.git` 접미사를 모두 받으려고 이름을 최소 일치로 잡습니다. */ + private fun parseOwnerAndName(githubRepoUrl: String): Pair? { + val match = REPO_URL_PATTERN.matchEntire(githubRepoUrl.trim()) ?: return null + + return match.groupValues[1] to match.groupValues[2] + } + + // 나머지 필드는 Spring Boot 기본 설정(FAIL_ON_UNKNOWN_PROPERTIES=false)이 무시한다. + private data class GithubRepositoryResponse( + val id: Long, + ) + + companion object { + /** + * 부분 일치로 찾으면 `notgithub.com/o/n`처럼 호스트가 다른 URL도 통과합니다. 호출 대상이 api.github.com으로 + * 고정이라 다른 곳을 찌를 수는 없지만, 사용자가 준 적 없는 저장소가 등록됩니다. 그래서 문자열 전체를 맞춥니다. + * + * 소유자·이름을 GitHub 허용 문자로 좁힌 것도 쿼리스트링이 이름에 묻어 들어가지 않게 하려는 것입니다. + */ + private val REPO_URL_PATTERN = Regex("""(?:https?://)?(?:www\.)?github\.com/([\w.-]+)/([\w.-]+?)(?:\.git)?/?""") + } +} diff --git a/src/main/kotlin/com/nexters/gitit/infrastructure/github/GithubClientConfiguration.kt b/src/main/kotlin/com/nexters/gitit/infrastructure/github/GithubClientConfiguration.kt new file mode 100644 index 0000000..00bde51 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/infrastructure/github/GithubClientConfiguration.kt @@ -0,0 +1,61 @@ +package com.nexters.gitit.infrastructure.github + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.retry.RetryPolicy +import org.springframework.core.retry.RetryTemplate +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.web.client.RestClient +import java.io.IOException +import java.time.Duration + +/** + * GitHub API 호출용 클라이언트를 빈으로 둡니다. + * + * 호출부에서 직접 만들면 인증 헤더나 타임아웃 같은 공통 설정을 나중에 붙일 때 손댈 자리가 여러 곳으로 + * 흩어지고, 테스트에서 가짜 클라이언트로 바꿔 끼울 수도 없습니다. + */ +@Configuration +class GithubClientConfiguration { + @Bean + fun githubRestClient(): RestClient = + RestClient + .builder() + .baseUrl(GITHUB_API_BASE_URL) + .requestFactory(timeoutBoundRequestFactory()) + .requestInterceptor { request, body, execution -> RETRY.execute { execution.execute(request, body) } } + .build() + + /** + * 저장소 하나를 읽는 단건 조회라 정상이면 수백 ms 안에 끝납니다. + * + * 타임아웃은 재시도 횟수와 곱해져 그대로 사용자 대기 시간이 됩니다 — `(연결 + 읽기) × (재시도 + 1) + 지연`. + * 값을 올릴 때는 이 곱을 같이 보고 정해야 합니다. + */ + private fun timeoutBoundRequestFactory() = + SimpleClientHttpRequestFactory().apply { + setConnectTimeout(CONNECT_TIMEOUT) + setReadTimeout(READ_TIMEOUT) + } + + companion object { + private const val GITHUB_API_BASE_URL = "https://api.github.com" + + private val CONNECT_TIMEOUT = Duration.ofSeconds(1) + private val READ_TIMEOUT = Duration.ofSeconds(1) + private val RETRY_DELAY = Duration.ofMillis(200) + private const val MAX_RETRIES = 1L + + // 조회뿐이라 몇 번을 보내도 부작용이 없다. 연결이 한 번 끊겼다고 등록이 실패하지 않을 만큼만 짧게 둔다. + // 응답을 받아낸 뒤의 5xx는 여기서 재시도하지 않는다 — 그건 GitHub이 실제로 답을 준 상태라 성격이 다르다. + private val RETRY = + RetryTemplate( + RetryPolicy + .builder() + .maxRetries(MAX_RETRIES) + .delay(RETRY_DELAY) + .includes(IOException::class.java) + .build(), + ) + } +} diff --git a/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/MongoProjectRepository.kt b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/MongoProjectRepository.kt new file mode 100644 index 0000000..11b350a --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/MongoProjectRepository.kt @@ -0,0 +1,28 @@ +package com.nexters.gitit.infrastructure.mongo + +import com.nexters.gitit.domain.project.Project +import com.nexters.gitit.domain.project.ProjectRepository +import org.springframework.dao.DuplicateKeyException +import org.springframework.stereotype.Repository + +@Repository +class MongoProjectRepository( + private val projectRepository: SpringDataProjectRepository, +) : ProjectRepository { + /** + * 회원과 저장소 조합에 걸린 유니크 인덱스를 최종 판정자로 씁니다. 요청이 연달아 들어와도 프로젝트가 둘로 늘지 않고, + * 밀린 쪽은 이긴 쪽 도큐먼트를 다시 읽어 돌려줍니다. + */ + override fun saveIfAbsent(project: Project): Project = + findByMemberIdAndQuizRepoId(project.memberId, project.quizRepoId) + ?: try { + projectRepository.save(project) + } catch (e: DuplicateKeyException) { + findByMemberIdAndQuizRepoId(project.memberId, project.quizRepoId) ?: throw e + } + + private fun findByMemberIdAndQuizRepoId( + memberId: String, + quizRepoId: String, + ): Project? = projectRepository.findByMemberIdAndQuizRepoIdAndDeletedAtIsNull(memberId, quizRepoId) +} diff --git a/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/MongoQuizRepoRepository.kt b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/MongoQuizRepoRepository.kt new file mode 100644 index 0000000..8889a58 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/MongoQuizRepoRepository.kt @@ -0,0 +1,25 @@ +package com.nexters.gitit.infrastructure.mongo + +import com.nexters.gitit.domain.quizrepo.QuizRepo +import com.nexters.gitit.domain.quizrepo.QuizRepoRepository +import org.springframework.dao.DuplicateKeyException +import org.springframework.stereotype.Repository + +@Repository +class MongoQuizRepoRepository( + private val quizRepoRepository: SpringDataQuizRepoRepository, +) : QuizRepoRepository { + /** + * 조회와 저장 사이에 다른 요청이 끼어들 수 있어 githubRepoId 유니크 인덱스를 최종 판정자로 씁니다. + * 저장에서 밀린 쪽은 이긴 쪽 도큐먼트를 다시 읽어 돌려주므로, 호출부에는 경합이 드러나지 않습니다. + */ + override fun saveIfAbsent(quizRepo: QuizRepo): QuizRepo = + findByGithubRepoId(quizRepo.githubRepoId) + ?: try { + quizRepoRepository.save(quizRepo) + } catch (e: DuplicateKeyException) { + findByGithubRepoId(quizRepo.githubRepoId) ?: throw e + } + + private fun findByGithubRepoId(githubRepoId: String): QuizRepo? = quizRepoRepository.findByGithubRepoIdAndDeletedAtIsNull(githubRepoId) +} diff --git a/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/SpringDataProjectRepository.kt b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/SpringDataProjectRepository.kt new file mode 100644 index 0000000..5d33603 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/SpringDataProjectRepository.kt @@ -0,0 +1,11 @@ +package com.nexters.gitit.infrastructure.mongo + +import com.nexters.gitit.domain.project.Project +import org.springframework.data.mongodb.repository.MongoRepository + +interface SpringDataProjectRepository : MongoRepository { + fun findByMemberIdAndQuizRepoIdAndDeletedAtIsNull( + memberId: String, + quizRepoId: String, + ): Project? +} diff --git a/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/SpringDataQuizRepoRepository.kt b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/SpringDataQuizRepoRepository.kt new file mode 100644 index 0000000..25ea242 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/infrastructure/mongo/SpringDataQuizRepoRepository.kt @@ -0,0 +1,8 @@ +package com.nexters.gitit.infrastructure.mongo + +import com.nexters.gitit.domain.quizrepo.QuizRepo +import org.springframework.data.mongodb.repository.MongoRepository + +interface SpringDataQuizRepoRepository : MongoRepository { + fun findByGithubRepoIdAndDeletedAtIsNull(githubRepoId: String): QuizRepo? +} diff --git a/src/main/kotlin/com/nexters/gitit/ui/project/ProjectController.kt b/src/main/kotlin/com/nexters/gitit/ui/project/ProjectController.kt new file mode 100644 index 0000000..0b6874f --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/ui/project/ProjectController.kt @@ -0,0 +1,27 @@ +package com.nexters.gitit.ui.project + +import com.nexters.gitit.application.RegisterProject +import com.nexters.gitit.ui.common.ApiResponse +import com.nexters.gitit.ui.common.LoginMember +import com.nexters.gitit.ui.project.dto.RegisterProjectRequest +import com.nexters.gitit.ui.project.dto.RegisterProjectResponse +import jakarta.validation.Valid +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +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/projects", produces = [APPLICATION_JSON_VALUE]) +class ProjectController( + private val registerProject: RegisterProject, +) : ProjectControllerDocs { + // 같은 저장소를 다시 등록해도 프로젝트가 새로 생기지 않고 기존 것이 돌아오므로 201이 아닌 200으로 응답합니다. + @PostMapping + override fun registerProject( + @LoginMember memberId: String, + @Valid @RequestBody request: RegisterProjectRequest, + ): ApiResponse = + ApiResponse.success(RegisterProjectResponse.from(registerProject(request.toCommand(memberId)))) +} diff --git a/src/main/kotlin/com/nexters/gitit/ui/project/ProjectControllerDocs.kt b/src/main/kotlin/com/nexters/gitit/ui/project/ProjectControllerDocs.kt new file mode 100644 index 0000000..63275d7 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/ui/project/ProjectControllerDocs.kt @@ -0,0 +1,56 @@ +package com.nexters.gitit.ui.project + +import com.nexters.gitit.ui.common.ApiResponse +import com.nexters.gitit.ui.project.dto.RegisterProjectRequest +import com.nexters.gitit.ui.project.dto.RegisterProjectResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE +import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse + +@Tag(name = "Project", description = "프로젝트 API") +interface ProjectControllerDocs { + @Operation( + summary = "프로젝트 등록", + description = + "GitHub 저장소를 학습할 프로젝트로 등록합니다. 문제는 등록 직후가 아니라 몇 분 뒤에 채워지므로, 응답의 status가 " + + "완료 상태가 될 때까지는 아직 풀 문제가 없습니다. 다른 회원이 이미 등록한 저장소라면 그 문제 세트를 함께 씁니다. " + + "같은 저장소를 다시 등록해도 프로젝트가 새로 생기지 않고 기존 프로젝트가 그대로 돌아오며, 난이도도 바뀌지 않습니다.", + ) + @ApiResponses( + SwaggerApiResponse( + responseCode = "200", + description = "등록 성공", + ), + SwaggerApiResponse( + responseCode = "400", + description = "githubRepoUrl이 비어 있거나, GitHub에 없는 저장소이거나, 문제를 낼 수 없다고 판정된 저장소", + content = [ + Content( + mediaType = APPLICATION_JSON_VALUE, + examples = [ + ExampleObject(name = "필수 값 누락", value = INVALID_INPUT_EXAMPLE), + ExampleObject(name = "등록할 수 없는 저장소", value = INVALID_REPOSITORY_EXAMPLE), + ], + ), + ], + ), + ) + fun registerProject( + memberId: String, + request: RegisterProjectRequest, + ): ApiResponse + + companion object { + // 401은 OpenApiConfig의 loginMemberSecurityCustomizer가 @LoginMember 파라미터를 보고 자동으로 붙이므로 여기 적지 않습니다. + private const val INVALID_INPUT_EXAMPLE = + """{"success":false,"data":null,"code":"COMMON-001","message":"잘못된 요청입니다","errors":[{"field":"githubRepoUrl","message":"githubRepoUrl은 필수입니다"}]}""" + + // URL 형식이 틀린 것과 GitHub에 없는 것을 구분하지 않습니다. 사용자가 할 일은 어느 쪽이든 URL을 다시 확인하는 것이라 같습니다. + private const val INVALID_REPOSITORY_EXAMPLE = + """{"success":false,"data":null,"code":"COMMON-001","message":"유효하지 않은 GitHub 저장소입니다","errors":null}""" + } +} diff --git a/src/main/kotlin/com/nexters/gitit/ui/project/dto/RegisterProjectRequest.kt b/src/main/kotlin/com/nexters/gitit/ui/project/dto/RegisterProjectRequest.kt new file mode 100644 index 0000000..3a33451 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/ui/project/dto/RegisterProjectRequest.kt @@ -0,0 +1,25 @@ +package com.nexters.gitit.ui.project.dto + +import com.nexters.gitit.application.RegisterProject +import com.nexters.gitit.domain.project.QuizLevel +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +/** + * quizLevel에는 검증 애너테이션을 붙이지 않습니다. 실제 enum이라 정의에 없는 값이 오면 본문을 읽는 단계에서 + * 이미 걸리고, 그 결과도 똑같이 400이라 검증을 한 겹 더 두면 같은 판정을 두 곳에서 관리하게 됩니다. + */ +data class RegisterProjectRequest( + @field:Schema(description = "등록할 GitHub 저장소 URL", example = "https://github.com/Nexters/Git-it-Server") + @field:NotBlank(message = "githubRepoUrl은 필수입니다") + val githubRepoUrl: String, + @field:Schema(description = "풀고 싶은 문제 난이도 - 깊이만 나누고 직급과는 무관합니다", example = "L2") + val quizLevel: QuizLevel, +) { + fun toCommand(memberId: String) = + RegisterProject.Command( + memberId = memberId, + githubRepoUrl = githubRepoUrl, + quizLevel = quizLevel, + ) +} diff --git a/src/main/kotlin/com/nexters/gitit/ui/project/dto/RegisterProjectResponse.kt b/src/main/kotlin/com/nexters/gitit/ui/project/dto/RegisterProjectResponse.kt new file mode 100644 index 0000000..2577cd7 --- /dev/null +++ b/src/main/kotlin/com/nexters/gitit/ui/project/dto/RegisterProjectResponse.kt @@ -0,0 +1,20 @@ +package com.nexters.gitit.ui.project.dto + +import com.nexters.gitit.application.RegisterProject +import com.nexters.gitit.domain.quizrepo.QuizRepoStatus +import io.swagger.v3.oas.annotations.media.Schema + +data class RegisterProjectResponse( + @field:Schema(description = "등록된 프로젝트 id. 이 값으로 프로젝트 상세를 조회합니다") + val projectId: String, + @field:Schema(description = "문제 생성 진행 상태. 갓 등록했다면 아직 READY이고, 문제는 몇 분 뒤에 채워집니다") + val status: QuizRepoStatus, +) { + companion object { + fun from(result: RegisterProject.Result) = + RegisterProjectResponse( + projectId = result.projectId, + status = result.status, + ) + } +} diff --git a/src/test/kotlin/com/nexters/gitit/application/RegisterProjectTest.kt b/src/test/kotlin/com/nexters/gitit/application/RegisterProjectTest.kt new file mode 100644 index 0000000..1171fd6 --- /dev/null +++ b/src/test/kotlin/com/nexters/gitit/application/RegisterProjectTest.kt @@ -0,0 +1,129 @@ +package com.nexters.gitit.application + +import com.nexters.gitit.TestcontainersConfiguration +import com.nexters.gitit.domain.exception.BaseException +import com.nexters.gitit.domain.exception.ErrorCode +import com.nexters.gitit.domain.project.QuizLevel +import com.nexters.gitit.domain.quizrepo.GithubRepositoryResolver +import com.nexters.gitit.domain.quizrepo.QuizGenerationRequested +import com.nexters.gitit.domain.quizrepo.QuizRepo +import com.nexters.gitit.domain.quizrepo.QuizRepoStatus +import com.nexters.gitit.infrastructure.mongo.SpringDataProjectRepository +import com.nexters.gitit.infrastructure.mongo.SpringDataQuizRepoRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.context.event.EventListener +import org.springframework.test.context.bean.override.mockito.MockitoBean + +@SpringBootTest +@Import(TestcontainersConfiguration::class, RegisterProjectTest.QuizGenerationRequestedCaptor::class) +class RegisterProjectTest( + @Autowired private val registerProject: RegisterProject, + @Autowired private val quizRepoRepository: SpringDataQuizRepoRepository, + @Autowired private val projectRepository: SpringDataProjectRepository, + @Autowired private val eventCaptor: QuizGenerationRequestedCaptor, +) { + // 실제 구현은 GitHub API를 호출하므로 테스트에서 진짜로 돌릴 수 없다. MongoDB는 실제 구현을 쓴다. + @MockitoBean + private lateinit var githubRepositoryResolver: GithubRepositoryResolver + + @BeforeEach + fun clear() { + // 컨테이너는 테스트 클래스 간 공유되므로 도큐먼트만 비운다. 인덱스는 유지된다. + quizRepoRepository.deleteAll() + projectRepository.deleteAll() + eventCaptor.clear() + given(githubRepositoryResolver.resolve(REPO_URL)).willReturn(REPO_ID) + } + + @Test + fun `처음 등록하면 문제 저장소를 만들고 그 난이도로 프로젝트를 만들며 문제 생성을 요청한다`() { + val result = registerProject(commandOf(memberId = "member-1", quizLevel = QuizLevel.L2)) + + result.status shouldBe QuizRepoStatus.READY + + val quizRepo = quizRepoRepository.findByGithubRepoIdAndDeletedAtIsNull(REPO_ID).shouldNotBeNull() + quizRepo.githubRepoUrl shouldBe REPO_URL + + val project = projectOf("member-1") + project.id shouldBe result.projectId + project.quizRepoId shouldBe quizRepo.id + project.quizLevel shouldBe QuizLevel.L2 + + eventCaptor.received shouldBe listOf(QuizGenerationRequested(quizRepo.id)) + } + + @Test + fun `다른 회원이 같은 저장소를 등록하면 문제 저장소는 그대로 두고 프로젝트만 추가한다`() { + val first = registerProject(commandOf(memberId = "member-1", quizLevel = QuizLevel.L2)) + eventCaptor.clear() + + val second = registerProject(commandOf(memberId = "member-2", quizLevel = QuizLevel.L3)) + + second.projectId shouldNotBe first.projectId + quizRepoRepository.count() shouldBe 1 + projectRepository.count() shouldBe 2 + // 이미 생성이 걸려 있으므로 중복 요청하지 않는다. + eventCaptor.received.shouldBeEmpty() + } + + @Test + fun `같은 회원이 난이도를 바꿔 다시 등록해도 기존 프로젝트를 그대로 둔다`() { + val first = registerProject(commandOf(memberId = "member-1", quizLevel = QuizLevel.L2)) + + val second = registerProject(commandOf(memberId = "member-1", quizLevel = QuizLevel.L3)) + + second.projectId shouldBe first.projectId + projectRepository.count() shouldBe 1 + projectOf("member-1").quizLevel shouldBe QuizLevel.L2 + } + + @Test + fun `문제를 낼 수 없다고 판정된 저장소면 프로젝트를 만들지 않고 거절 사유를 그대로 알린다`() { + quizRepoRepository.save(QuizRepo(githubRepoId = REPO_ID, githubRepoUrl = REPO_URL).apply { reject(ErrorCode.NOT_FOUND) }) + + val exception = shouldThrow { registerProject(commandOf(memberId = "member-1", quizLevel = QuizLevel.L2)) } + + exception.errorCode shouldBe ErrorCode.NOT_FOUND + projectRepository.count() shouldBe 0 + } + + private fun projectOf(memberId: String) = projectRepository.findAll().singleOrNull { it.memberId == memberId }.shouldNotBeNull() + + private fun commandOf( + memberId: String, + quizLevel: QuizLevel, + ) = RegisterProject.Command( + memberId = memberId, + githubRepoUrl = REPO_URL, + quizLevel = quizLevel, + ) + + /** + * 이벤트가 실제로 나갔는지만 봅니다. 퍼블리셔를 목으로 바꾸면 컨텍스트 전체의 이벤트가 죽어 다른 검증까지 흔들립니다. + */ + class QuizGenerationRequestedCaptor { + val received = mutableListOf() + + @EventListener + fun capture(event: QuizGenerationRequested) { + received += event + } + + fun clear() = received.clear() + } + + companion object { + private const val REPO_URL = "https://github.com/spring-projects/spring-petclinic" + private const val REPO_ID = "7517918" + } +} diff --git a/src/test/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoRepositoryTest.kt b/src/test/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoRepositoryTest.kt new file mode 100644 index 0000000..6f12681 --- /dev/null +++ b/src/test/kotlin/com/nexters/gitit/domain/quizrepo/QuizRepoRepositoryTest.kt @@ -0,0 +1,52 @@ +package com.nexters.gitit.domain.quizrepo + +import com.nexters.gitit.TestcontainersConfiguration +import com.nexters.gitit.infrastructure.mongo.MongoAuditingConfiguration +import com.nexters.gitit.infrastructure.mongo.SpringDataQuizRepoRepository +import com.nexters.gitit.infrastructure.time.ClockConfiguration +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest +import org.springframework.context.annotation.Import +import org.springframework.dao.DuplicateKeyException + +/** + * 등록 유스케이스가 동시 요청을 이 유니크 인덱스에 맡기고 있으므로, 인덱스가 실제로 걸리는지를 여기서 못 박습니다. + * 인덱스가 조용히 빠지면 유스케이스의 중복 처리 분기는 영영 실행되지 않고 같은 저장소가 둘로 생깁니다. + */ +@DataMongoTest +@Import(TestcontainersConfiguration::class, MongoAuditingConfiguration::class, ClockConfiguration::class) +class QuizRepoRepositoryTest( + @Autowired private val quizRepoRepository: SpringDataQuizRepoRepository, +) { + @BeforeEach + fun clear() { + // 컨테이너는 테스트 클래스 간 공유되므로 도큐먼트만 비운다. 인덱스는 유지된다. + quizRepoRepository.deleteAll() + } + + @Test + fun `같은 githubRepoId로 두 번 저장하면 두 번째가 실패한다`() { + quizRepoRepository.save(quizRepoOf(githubRepoUrl = "https://github.com/Nexters/first")) + + // id를 재사용하면 같은 도큐먼트의 갱신이 되어버리므로, 별개의 인스턴스로 저장한다. + shouldThrow { + quizRepoRepository.save(quizRepoOf(githubRepoUrl = "https://github.com/Nexters/second")) + } + + quizRepoRepository + .findByGithubRepoIdAndDeletedAtIsNull(GITHUB_REPO_ID) + .shouldNotBeNull() + .githubRepoUrl shouldBe "https://github.com/Nexters/first" + } + + private fun quizRepoOf(githubRepoUrl: String) = QuizRepo(githubRepoId = GITHUB_REPO_ID, githubRepoUrl = githubRepoUrl) + + companion object { + private const val GITHUB_REPO_ID = "1310710749" + } +} diff --git a/src/test/kotlin/com/nexters/gitit/infrastructure/github/GithubApiRepositoryResolverTest.kt b/src/test/kotlin/com/nexters/gitit/infrastructure/github/GithubApiRepositoryResolverTest.kt new file mode 100644 index 0000000..8d899ca --- /dev/null +++ b/src/test/kotlin/com/nexters/gitit/infrastructure/github/GithubApiRepositoryResolverTest.kt @@ -0,0 +1,24 @@ +package com.nexters.gitit.infrastructure.github + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +/** + * 목으로 막으면 우리가 짠 try/catch와 RestClient 동작만 확인하게 됩니다. 알고 싶은 것은 "이 URL 규칙과 응답 + * 파싱이 진짜 GitHub에서 통하는가"라서 실제로 부릅니다. + */ +@Tag("network") +class GithubApiRepositoryResolverTest { + private val resolver = GithubApiRepositoryResolver(GithubClientConfiguration().githubRestClient()) + + @Test + fun `공개된 저장소면 GitHub id를 반환한다`() { + resolver.resolve("https://github.com/Nexters/Git-it-Server") shouldBe "1310710749" + } + + @Test + fun `GitHub에 없는 저장소면 null을 반환한다`() { + resolver.resolve("https://github.com/nexters/no-such-repository-for-git-it") shouldBe null + } +} diff --git a/src/test/kotlin/com/nexters/gitit/infrastructure/github/GithubRepoUrlValidationTest.kt b/src/test/kotlin/com/nexters/gitit/infrastructure/github/GithubRepoUrlValidationTest.kt new file mode 100644 index 0000000..da439ff --- /dev/null +++ b/src/test/kotlin/com/nexters/gitit/infrastructure/github/GithubRepoUrlValidationTest.kt @@ -0,0 +1,29 @@ +package com.nexters.gitit.infrastructure.github + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.springframework.web.client.RestClient + +/** + * 여기서 새면 사용자가 준 적 없는 저장소가 등록되므로, 실제 호출이 필요한 `GithubApiRepositoryResolverTest`와 달리 + * 기본 test에 두어 늘 돌게 합니다. + * + * 클라이언트를 닿지 않는 주소로 둔 것은 "걸러진다"와 "호출조차 안 한다"를 함께 확인하려는 것입니다. + * 요청이 한 번이라도 나가면 null이 아니라 예외로 실패합니다. + */ +class GithubRepoUrlValidationTest { + private val resolver = GithubApiRepositoryResolver(RestClient.create("http://localhost:1")) + + @Test + fun `호스트가 github_com이 아니면 등록을 거절한다`() { + resolver.resolve("https://notgithub.com/Nexters/Git-it-Server") shouldBe null + resolver.resolve("https://github.com.nexters.com/Nexters/Git-it-Server") shouldBe null + resolver.resolve("https://gitit.nexters.com/o/n?ref=github.com/Nexters/Git-it-Server") shouldBe null + } + + @Test + fun `저장소 하나를 가리키지 않으면 등록을 거절한다`() { + resolver.resolve("https://github.com/Nexters") shouldBe null + resolver.resolve("https://github.com/Nexters/Git-it-Server/tree/main") shouldBe null + } +}