Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import hs.kr.entrydsm.application.adapterin.web.dto.request.UpdateTypeRequest
import hs.kr.entrydsm.application.adapterin.web.dto.response.CreateApplicantResponse
import hs.kr.entrydsm.application.adapterin.web.dto.response.LandingResponse
import hs.kr.entrydsm.application.application.port.`in`.ApplicationPort
import hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult
import hs.kr.entrydsm.application.application.port.`in`.command.CreateApplicantCommand
import hs.kr.entrydsm.application.application.port.`in`.command.SubmitApplicationCommand
import hs.kr.entrydsm.application.application.port.`in`.command.UpdateFamilyCommand
Expand Down Expand Up @@ -41,6 +42,12 @@ import org.springframework.web.bind.annotation.RestController
class ApplicationController(
private val applicationPort: ApplicationPort,
) {
// 신뢰된 사용자 헤더의 본인 계정으로만 작성 중 원서를 조회한다.
@GetMapping
fun getDraftApplication(
@RequestHeader(USER_ID_HEADER) accountId: Long,
): ApiResponse<DraftApplicationResult> = ApiResponse(data = applicationPort.findDraftApplication(accountId))

@GetMapping("/landing")
fun getLanding(
@RequestHeader(USER_ID_HEADER) accountId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ class ApplicationGrpcServiceTest {
}

private class FakeApplicationPort : ApplicationPort {
override fun findDraftApplication(accountId: Long): hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult = error("not used")
var snapshot: ApplicationSnapshotResult? = null
var examineeNumberUpdate: Pair<Long, String>? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,91 @@ import org.junit.Assert.assertThrows
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import hs.kr.entrydsm.application.application.exception.ApplicationAccessDeniedException
import hs.kr.entrydsm.application.application.service.ApplicationCommandService
import hs.kr.entrydsm.application.application.port.out.ApplicantRepository
import hs.kr.entrydsm.application.application.port.out.ApplicationPeriodReader
import hs.kr.entrydsm.application.domain.model.*
import hs.kr.entrydsm.application.domain.enum.*
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.junit.Assert.assertTrue

class ApplicationControllerTest {
@Test
fun `미입력 작성 원서는 필수 구조와 null 및 저장 기본값을 JSON에 보존한다`() {
val fixture = DraftFixture(Applicant(id = 1, accountId = 10))
val response = fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT")).andReturn().response
assertEquals(200, response.status)
assertEquals(fixture.mapper.readTree("""{"success":true,"data":{"type":{"admissionType":null,"region":null,"graduationType":null,"graduationDate":null},"personal":{"photoFileId":null,"name":null,"phoneNumber":null,"gender":null,"birthdate":null,"specialAdmissionType":"NONE"},"family":{"guardianName":null,"guardianPhoneNumber":null,"guardianGender":null,"guardianRelation":null,"address":{"zipCode":null,"addressBase":null,"addressDetail":null}},"middleSchool":null,"evaluation":{"subjectGrades":[],"gedScores":null,"academicRecord":null,"certificates":null},"introduction":null,"studyPlan":null},"error":null}"""), fixture.mapper.readTree(response.contentAsString))
assertEquals(listOf(10L), fixture.lookups)
}

@Test
fun `저장 입력과 성적은 날짜 형식과 학기 순서를 지켜 반환하고 내부 정보는 제외한다`() {
val grades = SubjectGrades(SubjectGrade.A, SubjectGrade.B, SubjectGrade.C, SubjectGrade.D, SubjectGrade.E, SubjectGrade.X, SubjectGrade.A)
val applicant = Applicant(id = 99, accountId = 10, photoFileId = "photo_1", name = "지원자", phoneNumber = "01012345678",
gender = Gender.FEMALE, birthdate = java.time.LocalDate.of(2010, 3, 4), admissionType = AdmissionType.REGULAR,
region = Region.DAEJEON, graduationType = GraduationType.PROSPECTIVE, graduationDate = java.time.YearMonth.of(2027, 2),
guardianName = "보호자", guardianPhoneNumber = "01087654321", guardianGender = Gender.MALE, guardianRelation = "부",
zipCode = "01234", addressBase = "기본 주소", addressDetail = "상세 주소", introduction = "자기소개", studyPlan = "학업계획",
middleSchoolInfo = MiddleSchoolInfo("code", "중학교", "30101", "-", "담임", "반환금지 학교주소"),
academicRecord = AcademicRecord(absentCount = 1, lateCount = 2, earlyLeaveCount = 3, classAbsenceCount = 4, volunteerTime = 5,
isDsmAlgorithmAwarded = true, subjectGrades = linkedMapOf(SchoolSemester.THIRD_GRADE_FIRST_SEMESTER to grades,
SchoolSemester.SECOND_GRADE_FIRST_SEMESTER to grades), gedScores = GedScores(100, 90, 80, 70, 60)),
totalScore = 123.45, examineeNumber = "11001", passStatus = PassResultStatus.PASS)
val before = applicant.copy()
val fixture = DraftFixture(applicant)
val response = fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT")
.param("accountId", "20").param("applicantId", "99")).andReturn().response
assertEquals(200, response.status)
val expected = """{"success":true,"data":{"type":{"admissionType":"REGULAR","region":"DAEJEON","graduationType":"PROSPECTIVE","graduationDate":"2027-02"},"personal":{"photoFileId":"photo_1","name":"지원자","phoneNumber":"01012345678","gender":"FEMALE","birthdate":"2010-03-04","specialAdmissionType":"NONE"},"family":{"guardianName":"보호자","guardianPhoneNumber":"01087654321","guardianGender":"MALE","guardianRelation":"부","address":{"zipCode":"01234","addressBase":"기본 주소","addressDetail":"상세 주소"}},"middleSchool":{"schoolCode":"code","schoolName":"중학교","studentNumber":"30101","schoolPhone":"-","teacherName":"담임"},"evaluation":{"subjectGrades":[{"schoolSemester":"2-1","subjects":{"koreanGrade":"A","mathGrade":"B","englishGrade":"C","scienceGrade":"D","societyGrade":"E","technologyGrade":"X","historyGrade":"A"}},{"schoolSemester":"3-1","subjects":{"koreanGrade":"A","mathGrade":"B","englishGrade":"C","scienceGrade":"D","societyGrade":"E","technologyGrade":"X","historyGrade":"A"}}],"gedScores":{"koreanScore":100,"mathScore":90,"englishScore":80,"scienceScore":70,"societyScore":60,"technologyScore":null},"academicRecord":{"absentCount":1,"lateCount":2,"earlyLeaveCount":3,"classAbsenceCount":4,"volunteerTime":5},"certificates":{"isDsmAlgorithmAwarded":true,"isProgrammingCertified":false}},"introduction":"자기소개","studyPlan":"학업계획"},"error":null}"""
assertEquals(fixture.mapper.readTree(expected), fixture.mapper.readTree(response.contentAsString))
assertEquals(before, applicant)
assertEquals(listOf(10L), fixture.lookups)
}

@Test
fun `원서 없음과 모든 비작성 상태는 정해진 HTTP 오류로 응답한다`() {
for (status in listOf(null) + ApplicantStatus.entries.filter { it != ApplicantStatus.DRAFT }) {
val fixture = DraftFixture(status?.let { Applicant(id = 1, accountId = 10, status = it) })
val response = fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT")).andReturn().response
assertEquals("$status: ${response.contentAsString}", if (status == null) 404 else 400, response.status)
val root = fixture.mapper.readTree(response.contentAsString)
assertEquals(false, root.path("success").asBoolean())
org.junit.Assert.assertFalse(root.has("data"))
assertEquals(if (status == null) "APPLICANT_NOT_FOUND" else "APPLICATION_NOT_READABLE", root.path("error").path("code").asString())
if (status != null) assertEquals("작성 중인 원서만 조회할 수 있습니다.", root.path("error").path("message").asString())
}
}

@Test
fun `조회는 미인증과 학생 외 권한을 차단하고 요청 계정으로 타인 원서를 조회하지 않는다`() {
val fixture = DraftFixture(Applicant(id = 1, accountId = 20))
assertEquals(401, fixture.mvc.perform(get(DRAFT_URL)).andReturn().response.status)
for (role in listOf("ADMIN", "TEACHER", "")) {
assertEquals(403, fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", role)).andReturn().response.status)
}
assertTrue(fixture.lookups.isEmpty())
assertEquals(404, fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT")
.param("accountId", "20").param("applicantId", "1")).andReturn().response.status)
assertEquals(listOf(10L), fixture.lookups)
}

private class DraftFixture(applicant: Applicant?) {
val lookups = mutableListOf<Long>()
val mapper = tools.jackson.module.kotlin.jacksonMapperBuilder().build()
private val repository = java.lang.reflect.Proxy.newProxyInstance(javaClass.classLoader, arrayOf(ApplicantRepository::class.java)) { _, method, args ->
check(method.name == "findByAccountId") { "조회 중 예상하지 않은 저장소 호출: ${method.name}" }
val accountId = args[0] as Long
lookups += accountId
applicant?.takeIf { it.accountId == accountId }
} as ApplicantRepository
val mvc = MockMvcBuilders.standaloneSetup(ApplicationController(ApplicationCommandService(repository,
ApplicationPeriodReader { error("조회 중 기간 확인 금지") }, hs.kr.entrydsm.application.application.port.out.AccountPhoneValidator { _, _ -> error("전화번호 검증 금지") })))
.setMessageConverters(org.springframework.http.converter.json.JacksonJsonHttpMessageConverter(mapper))
.addInterceptors(ApplicationAuthorizationInterceptor())
.setControllerAdvice(GlobalExceptionHandler()).build()
}

@Test
fun middleSchoolPassesLiteralDashForBothContactFields() {
var command: UpdateMiddleSchoolCommand? = null
Expand Down Expand Up @@ -221,6 +304,7 @@ class ApplicationControllerTest {
}

private class FakeApplicationPort : ApplicationPort {
override fun findDraftApplication(accountId: Long): hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult = error("not used")
var createApplicantCommand: CreateApplicantCommand? = null

override fun listApplicants(): List<ApplicantResult> = emptyList()
Expand Down Expand Up @@ -256,6 +340,7 @@ class ApplicationControllerTest {
}

private companion object {
const val DRAFT_URL = "/api/application/v11/applicants"
val applicationStartAt: LocalDateTime = LocalDateTime.parse("2026-10-19T09:00:00")
val applicationEndAt: LocalDateTime = LocalDateTime.parse("2026-10-23T17:00:00")
val resultAnnouncedAt: LocalDateTime = LocalDateTime.parse("2026-10-30T10:00:00")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface ApplicantJpaRepository : JpaRepository<ApplicantJpaEntity, Long> {
a.birthdate as birthdate, a.phoneNumber as phoneNumber,
a.graduationType as graduationType, a.totalScore as totalScore,
a.status as status, a.submittedAt as submittedAt, a.gender as gender, a.addressBase as address,
a.statusVersion as statusVersion
a.statusVersion as statusVersion, a.zipCode as zipCode
from ApplicantJpaEntity a
left join a.middleSchoolInfo m
where a.status in :statuses
Expand Down Expand Up @@ -67,4 +67,5 @@ interface ApplicantSummaryRow {
val submittedAt: LocalDateTime?
val gender: Gender?
val address: String?
val zipCode: String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ class ApplicantPersistenceAdapter(
override fun findAllByAccountIdIn(accountIds: List<Long>): List<Applicant> =
applicantJpaRepository.findAllByAccountIdIn(accountIds).map { it.toDomain(includePassResults = false) }

// 일괄 발급의 주소 대체 좌표 조회에 필요한 우편번호도 전달한다.
@Transactional(readOnly = true)
override fun findSummariesByStatusIn(statuses: Set<ApplicantStatus>): List<ApplicantResult> =
applicantJpaRepository.findSummariesByStatusIn(statuses).map {
ApplicantResult(
zipCode = it.zipCode,
statusVersion = it.statusVersion,
applicantId = it.id,
accountId = it.accountId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class ApplicantSummaryQueryTest {
submittedAt = SUBMITTED_AT
gender = Gender.FEMALE
addressBase = "충청남도 천안시"
zipCode = "01234"
middleSchoolInfo = MiddleSchoolInfo(
schoolCode = "7031234",
schoolName = "대덕중학교",
Expand All @@ -105,6 +106,7 @@ class ApplicantSummaryQueryTest {
assertEquals(SUBMITTED_AT, summary.submittedAt)
assertEquals(Gender.FEMALE, summary.gender)
assertEquals("충청남도 천안시", summary.address)
assertEquals("01234", summary.zipCode)
}

/** 제출 검증이 중학교를 요구하지 않으므로 낸 원서에도 없을 수 있다. 목록이 그 지원자를 빠뜨리면 안 된다. */
Expand All @@ -116,6 +118,7 @@ class ApplicantSummaryQueryTest {

assertEquals(101L, summary.accountId)
assertNull(summary.schoolName)
assertNull(summary.zipCode)
assertNull(summary.totalScore)
assertNull(applicantJpaRepository.findByAccountId(101L)?.toDomain()?.totalScore)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hs.kr.entrydsm.application.administration.adapterout.distance
import hs.kr.entrydsm.admin.domain.enum.ErrorCode
import hs.kr.entrydsm.admin.domain.exception.AdminDomainException
import hs.kr.entrydsm.admin.domain.port.out.DistancePort
import hs.kr.entrydsm.application.administration.adapterout.repository.PostalCodeLocationJpaRepository
import java.io.IOException
import java.net.URI
import java.net.URLEncoder
Expand All @@ -27,16 +28,20 @@ class KakaoDistanceAdapter(
@Value("\${admin.maps.directions-url}") private val directionsUrl: String,
@Value("\${admin.maps.school-address}") private val schoolAddress: String,
@Value("\${admin.maps.timeout-ms}") private val timeoutMs: Long,
private val postalCodeLocations: PostalCodeLocationJpaRepository? = null,
) : DistancePort {
private val logger = LoggerFactory.getLogger(javaClass)
private val client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMs)).build()
private val schoolCoordinates by lazy { geocode(schoolAddress, "SCHOOL_GEOCODING") }

// 외부 응답과 예외 메시지 대신 단계와 안전한 원인만 남긴다.
override fun distanceFromSchool(address: String): Long {
// 우편번호 없는 기존 호출도 같은 경로 계산을 사용한다.
override fun distanceFromSchool(address: String): Long = distanceFromSchool(address, null)

// 검색 결과가 비었을 때만 원서 우편번호의 대표 좌표로 경로를 계산한다.
override fun distanceFromSchool(address: String, zipCode: String?): Long {
var stage = "APPLICANT_GEOCODING"
return try {
val origin = geocode(address, stage)
val origin = parseCoordinates(send("$geocodingUrl?query=${encode(address)}", stage), stage, zipCode)
stage = "SCHOOL_GEOCODING"
val destination = schoolCoordinates
stage = "DIRECTIONS"
Expand Down Expand Up @@ -77,9 +82,16 @@ class KakaoDistanceAdapter(
fun parseCoordinates(body: String): Coordinates = parseCoordinates(body, "APPLICANT_GEOCODING")

// 검색 결과 없음과 좌표 형식 오류를 구분한다.
private fun parseCoordinates(body: String, stage: String): Coordinates {
private fun parseCoordinates(body: String, stage: String, zipCode: String? = null): Coordinates {
val documents = objectMapper.readTree(body).path("documents")
if (documents.isArray && documents.isEmpty) unavailable(stage, "EMPTY_SEARCH")
if (documents.isArray && documents.isEmpty) {
if (stage == "APPLICANT_GEOCODING" && zipCode != null) {
postalCodeLocations?.findById(zipCode)?.orElse(null)?.let {
return Coordinates(it.longitude.toDouble(), it.latitude.toDouble())
}
}
unavailable(stage, "EMPTY_SEARCH")
}
val document = documents.path(0)
val x = document.path("x").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT")
val y = document.path("y").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package hs.kr.entrydsm.application.administration.adapterout.entity

import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.math.BigDecimal

@Entity
@Table(name = "postal_code_locations")
class PostalCodeLocationJpaEntity(
@Id
@Column(name = "zip_code", length = 5, columnDefinition = "char(5)")
val zipCode: String = "",
@Column(name = "latitude", nullable = false, precision = 17, scale = 14)
val latitude: BigDecimal = BigDecimal.ZERO,
@Column(name = "longitude", nullable = false, precision = 18, scale = 14)
val longitude: BigDecimal = BigDecimal.ZERO,
)
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,24 @@ class LocalApplicantDataAdapter(
)
}

// 원서 우편번호는 외부 응답 확장 없이 내부 모델에 직접 싣는다.
override fun findAll(filter: ApplicantFilter): List<Applicant> {
val screenings = screeningJpaRepository.findAll().associateBy { it.applicantId }
return source { applicationPort.listApplicants() }
.map { it.toGrpcApplicant().toApplicant(screenings[it.applicantId]).copy(applicationVersion = it.statusVersion) }
.map { it.toGrpcApplicant().toApplicant(screenings[it.applicantId]).copy(applicationVersion = it.statusVersion, zipCode = it.zipCode) }
.filter { it.matches(filter) }.sortedBy { it.id }
}

// 단건 조회에도 원서의 우편번호를 유지한다.
override fun findById(applicantId: Long): Applicant? =
applicantId.takeIf { it > 0 }?.let {
source(it) { applicationPort.findApplicant(it) }?.let { original ->
original.toGrpcApplicant().toApplicant(screeningJpaRepository.findById(it).orElse(null))
.copy(applicationVersion = original.statusVersion)
.copy(applicationVersion = original.statusVersion, zipCode = original.zipCode)
}
}

// 상세 조회에서도 동일한 원서 우편번호를 사용한다.
override fun findDetailById(applicantId: Long): ApplicantDetail? {
if (applicantId <= 0) return null
val applicant = source(applicantId) { applicationPort.findApplicant(applicantId) } ?: return null
Expand All @@ -89,7 +92,7 @@ class LocalApplicantDataAdapter(
return ApplicantDetail(
semesterScores = sourceForm.score?.semesterScores?.mapKeys { it.key.name } ?: emptyMap(),
version = form.statusVersion,
applicant = response.toApplicant(screeningJpaRepository.findById(applicantId).orElse(null)).copy(applicationVersion = form.statusVersion),
applicant = response.toApplicant(screeningJpaRepository.findById(applicantId).orElse(null)).copy(applicationVersion = form.statusVersion, zipCode = applicant.zipCode),
photoFileId = form.photoFileId.takeIf { form.hasPhotoFileId() },
introduction = form.introduction.takeIf { form.hasIntroduction() },
studyPlan = form.studyPlan.takeIf { form.hasStudyPlan() },
Expand Down
Loading
Loading