feat(gift): 운송장 등록을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:58 +09:00
parent 0751971bf3
commit 74bc1679b7
4 changed files with 967 additions and 0 deletions
@@ -0,0 +1,383 @@
import Combine
import Foundation
struct Response {
let data: Data
}
struct MoyaError: Error {
let response: Response?
}
struct ApiResponse<T: Decodable>: Decodable {
let success: Bool
let data: T?
}
struct ApiResponseWithoutData: Decodable {
let success: Bool
}
enum GiftStatus {
case received
case trackingRegistered
case canceled
}
struct SentGiftDetailDisplayModel {
let status: GiftStatus
}
struct GiftAddressDisplayModel {
let name: String
let phoneNumber: String
let address: String
}
struct GiftMailboxResponse: Decodable {
let name: String
let phoneNumber: String
let address: String
}
struct GiftDetailResponse: Decodable {
let direction: String
let status: String
let trackingRequired: Bool
let mailbox: GiftMailboxResponse?
}
enum GiftDisplayPolicy {
static func sentDetail(from detail: GiftDetailResponse) -> SentGiftDetailDisplayModel? {
guard detail.direction == "SENT" else { return nil }
switch detail.status {
case "CANCELED":
return SentGiftDetailDisplayModel(status: .canceled)
case "TRACKING_REGISTERED":
return SentGiftDetailDisplayModel(status: .trackingRegistered)
default:
return SentGiftDetailDisplayModel(status: .received)
}
}
}
enum I18n {
enum Common {
static let commonError = "common error"
}
}
struct RegisterGiftTrackingRequest {
let courierCompanyName: String
let trackingNumber: String
}
enum RepositoryEvent {
case success(Data)
case failure(Data?)
case pending
}
final class GiftRepository {
static var getGiftEvents: [RepositoryEvent] = []
static var registerEvents: [RepositoryEvent] = []
static var cancelEvents: [RepositoryEvent] = []
static var getGiftCallCount = 0
static var registerCallCount = 0
static var cancelCallCount = 0
static var registeredRequests: [RegisterGiftTrackingRequest] = []
static var pendingRegister: PassthroughSubject<Response, MoyaError>?
static var pendingCancel: PassthroughSubject<Response, MoyaError>?
static func reset() {
getGiftEvents = []
registerEvents = []
cancelEvents = []
getGiftCallCount = 0
registerCallCount = 0
cancelCallCount = 0
registeredRequests = []
pendingRegister = nil
pendingCancel = nil
}
func getGift(applicationNo: String) -> AnyPublisher<Response, MoyaError> {
Self.getGiftCallCount += 1
return Self.publisher(for: Self.next(from: &Self.getGiftEvents), pending: nil).publisher
}
func registerTracking(
applicationNo: String,
request: RegisterGiftTrackingRequest
) -> AnyPublisher<Response, MoyaError> {
Self.registerCallCount += 1
Self.registeredRequests.append(request)
let pending = PassthroughSubject<Response, MoyaError>()
let publisher = Self.publisher(for: Self.next(from: &Self.registerEvents), pending: pending)
Self.pendingRegister = publisher.pending
return publisher.publisher
}
func cancelGift(applicationNo: String) -> AnyPublisher<Response, MoyaError> {
Self.cancelCallCount += 1
let pending = PassthroughSubject<Response, MoyaError>()
let publisher = Self.publisher(for: Self.next(from: &Self.cancelEvents), pending: pending)
Self.pendingCancel = publisher.pending
return publisher.publisher
}
private static func next(from events: inout [RepositoryEvent]) -> RepositoryEvent {
guard !events.isEmpty else { return .pending }
return events.removeFirst()
}
private static func publisher(
for event: RepositoryEvent,
pending: PassthroughSubject<Response, MoyaError>?
) -> (publisher: AnyPublisher<Response, MoyaError>, pending: PassthroughSubject<Response, MoyaError>?) {
switch event {
case .success(let data):
return (Just(Response(data: data)).setFailureType(to: MoyaError.self).eraseToAnyPublisher(), nil)
case .failure(let data):
return (Fail(error: MoyaError(response: data.map(Response.init(data:)))).eraseToAnyPublisher(), nil)
case .pending:
guard let pending else { fatalError("Missing pending publisher") }
return (pending.eraseToAnyPublisher(), pending)
}
}
}
private struct Check {
private(set) var assertions = 0
mutating func expect(
_ condition: @autoclosure () -> Bool,
_ message: String
) {
assertions += 1
guard condition() else { fatalError("Assertion \(assertions) failed: \(message)") }
}
}
private func runMainLoop() {
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
}
private func response(_ json: String) -> Data {
Data(json.utf8)
}
private let validDetail = response("""
{"success":true,"data":{"direction":"SENT","status":"RECEIVED","trackingRequired":true,"mailbox":{"name":"Soda mailbox","phoneNumber":"010-0000-0000","address":"Seoul"}}}
""")
private let unavailableDetail = response("""
{"success":true,"data":{"direction":"SENT","status":"RECEIVED","trackingRequired":false,"mailbox":{"name":"Soda mailbox","phoneNumber":"010-0000-0000","address":"Seoul"}}}
""")
private let missingMailboxDetail = response("""
{"success":true,"data":{"direction":"SENT","status":"RECEIVED","trackingRequired":true,"mailbox":null}}
""")
private let trackingRegisteredDetail = response("""
{"success":true,"data":{"direction":"SENT","status":"TRACKING_REGISTERED","trackingRequired":true,"mailbox":{"name":"Soda mailbox","phoneNumber":"010-0000-0000","address":"Seoul"}}}
""")
private let mutationSuccess = response("{\"success\":true}")
private let mutationFailure = response("{\"success\":false,\"message\":\"request failed\"}")
private func loadValid(
onUnavailable: @escaping (String) -> Void = { _ in },
onMutationCompleted: @escaping (String, @escaping (Bool) -> Void) -> Void = { _, completion in completion(true) }
) -> GiftTrackingRegistrationViewModel {
GiftRepository.getGiftEvents = [.success(validDetail)]
let viewModel = GiftTrackingRegistrationViewModel(
applicationNo: "GIFT-001",
onUnavailable: onUnavailable,
onMutationCompleted: onMutationCompleted
)
viewModel.fetch()
runMainLoop()
return viewModel
}
@main
enum GiftTrackingRegistrationCheck {
static func main() {
var check = Check()
for length in 0...15 {
let digits = String(repeating: "0", count: length)
check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingEditing(digits) == (length <= 14), "editing boundary at \(length) ASCII digits")
check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingSubmission(digits) == (10...14).contains(length), "submission boundary at \(length) ASCII digits")
}
for invalidNumber in ["00-12 34", " 0012345678", "0012345678 ", "0012345678\n", "00123\t5678", "00123a5678", "0012345678", "٠١٢٣٤٥٦٧٨٩", "00123😀5678", "0012345678\u{200B}"] {
check.expect(!GiftTrackingRegistrationViewModel.acceptsTrackingEditing(invalidNumber), "editing rejects the entire invalid replacement: \(invalidNumber.debugDescription)")
check.expect(!GiftTrackingRegistrationViewModel.acceptsTrackingSubmission(invalidNumber), "submission rejects non-ASCII-digit input: \(invalidNumber.debugDescription)")
}
check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingEditing("0123456789"), "editing accepts every ASCII digit")
check.expect(GiftTrackingRegistrationViewModel.acceptsTrackingSubmission("0123456789"), "submission accepts every ASCII digit")
GiftRepository.reset()
var unavailableRoutes: [String] = []
GiftRepository.getGiftEvents = [.success(unavailableDetail)]
let unavailableViewModel = GiftTrackingRegistrationViewModel(applicationNo: "GIFT-UNAVAILABLE") {
unavailableRoutes.append($0)
}
unavailableViewModel.fetch()
runMainLoop()
check.expect(unavailableViewModel.summary == nil, "unavailable detail clears the summary")
check.expect(unavailableViewModel.mailbox == nil, "unavailable detail clears the mailbox")
check.expect(!unavailableViewModel.canRegister, "unavailable detail is not registrable")
check.expect(unavailableViewModel.shouldReturnToDetail, "unavailable detail schedules a return")
check.expect(unavailableViewModel.isShowPopup, "unavailable detail shows the existing error toast")
unavailableViewModel.errorToastDidDismiss()
check.expect(unavailableRoutes.isEmpty, "visible error toast does not navigate early")
unavailableViewModel.isShowPopup = false
unavailableViewModel.errorToastDidDismiss()
check.expect(unavailableRoutes == ["GIFT-UNAVAILABLE"], "dismissed unavailable toast returns once")
unavailableViewModel.errorToastDidDismiss()
check.expect(unavailableRoutes.count == 1, "unavailable return remains exactly once")
GiftRepository.reset()
GiftRepository.getGiftEvents = [.success(missingMailboxDetail)]
let missingMailboxViewModel = GiftTrackingRegistrationViewModel(applicationNo: "GIFT-MAILBOX")
missingMailboxViewModel.fetch()
runMainLoop()
check.expect(missingMailboxViewModel.summary == nil, "missing mailbox does not expose a summary")
check.expect(missingMailboxViewModel.mailbox == nil, "missing mailbox is not registrable")
check.expect(missingMailboxViewModel.errorMessage == "받을 주소가 등록되어 있지 않습니다.", "missing mailbox uses the existing message")
check.expect(missingMailboxViewModel.isShowPopup, "missing mailbox shows the existing toast")
check.expect(!missingMailboxViewModel.shouldReturnToDetail, "missing mailbox remains retryable")
GiftRepository.reset()
GiftRepository.getGiftEvents = [.success(trackingRegisteredDetail)]
let trackingRegisteredViewModel = GiftTrackingRegistrationViewModel(applicationNo: "GIFT-TRACKING")
trackingRegisteredViewModel.fetch()
runMainLoop()
check.expect(!trackingRegisteredViewModel.canCancel, "tracking-registered detail cannot be canceled")
GiftRepository.reset()
var registrationRoutes: [String] = []
var registrationCompletions: [(Bool) -> Void] = []
let registrationViewModel = loadValid(onMutationCompleted: { applicationNo, completion in
registrationRoutes.append(applicationNo)
registrationCompletions.append(completion)
})
check.expect(registrationViewModel.summary != nil, "valid detail loads its summary")
check.expect(registrationViewModel.mailbox != nil, "valid detail loads its mailbox")
check.expect(registrationViewModel.canRegister, "valid detail remains entry-eligible")
check.expect(!registrationViewModel.canSubmit, "missing courier cannot submit")
check.expect(registrationViewModel.canCancel, "loaded registration can be canceled without form input")
registrationViewModel.selectedCourier = " \n "
registrationViewModel.trackingNumber = "0012345678"
check.expect(!registrationViewModel.canSubmit, "whitespace courier cannot submit")
registrationViewModel.selectedCourier = " CJ대한통운 "
registrationViewModel.trackingNumber = " \n "
check.expect(!registrationViewModel.canSubmit, "whitespace tracking number cannot submit")
for invalidNumber in ["", "123456789", "123456789012345", "00-12 34", " 0012345678", "0012345678\n", "00123a5678", "0012345678", "٠١٢٣٤٥٦٧٨٩", "00123\t5678", "00123😀5678"] {
registrationViewModel.trackingNumber = invalidNumber
check.expect(!registrationViewModel.canSubmit, "invalid tracking number cannot submit: \(invalidNumber.debugDescription)")
registrationViewModel.registerTracking()
check.expect(GiftRepository.registerCallCount == 0, "invalid tracking number never reaches the repository")
}
for length in 10...14 {
registrationViewModel.trackingNumber = String(repeating: "0", count: length)
check.expect(registrationViewModel.canSubmit, "\(length) ASCII digits can submit")
}
registrationViewModel.trackingNumber = "0012345678"
check.expect(registrationViewModel.canCancel, "loaded registration can be canceled")
GiftRepository.registerEvents = [.pending]
registrationViewModel.registerTracking()
check.expect(GiftRepository.registerCallCount == 1, "registration calls the repository once")
check.expect(GiftRepository.registeredRequests.first?.courierCompanyName == " CJ대한통운 ", "courier display string is preserved")
check.expect(GiftRepository.registeredRequests.first?.trackingNumber == "0012345678", "tracking number preserves leading zeros exactly")
check.expect(registrationViewModel.isSubmitting, "registration retains the shared mutation lock")
check.expect(registrationViewModel.canRegister, "mutation lock does not change entry eligibility")
check.expect(!registrationViewModel.canSubmit, "shared mutation lock blocks duplicate registration")
check.expect(!registrationViewModel.canCancel, "shared mutation lock blocks cancellation overlap")
let fetchesBeforeOverlap = GiftRepository.getGiftCallCount
registrationViewModel.fetch()
registrationViewModel.registerTracking()
registrationViewModel.confirmCancel()
check.expect(GiftRepository.getGiftCallCount == fetchesBeforeOverlap, "shared mutation lock blocks fetch overlap")
check.expect(GiftRepository.registerCallCount == 1, "shared mutation lock blocks duplicate POST")
check.expect(GiftRepository.cancelCallCount == 0, "shared mutation lock blocks cross-mutation POST")
GiftRepository.pendingRegister?.send(Response(data: mutationSuccess))
GiftRepository.pendingRegister?.send(completion: .finished)
runMainLoop()
check.expect(registrationViewModel.isShowSuccessToast, "registration success uses a separate success toast")
check.expect(registrationViewModel.successMessage == "운송장이 등록되었습니다.", "registration success toast copy is stable")
check.expect(registrationViewModel.isSubmitting, "registration keeps the lock until toast dismissal")
check.expect(registrationRoutes.isEmpty, "registration does not navigate before toast dismissal")
registrationViewModel.successToastDidDismiss()
check.expect(registrationRoutes.isEmpty, "visible success toast cannot trigger navigation")
registrationViewModel.isShowSuccessToast = false
registrationViewModel.successToastDidDismiss()
check.expect(registrationRoutes == ["GIFT-001"], "dismissed success toast navigates once")
registrationViewModel.successToastDidDismiss()
check.expect(registrationRoutes.count == 1, "success navigation remains exactly once")
GiftRepository.getGiftEvents = [.success(validDetail)]
registrationCompletions[0](false)
runMainLoop()
check.expect(!registrationViewModel.isSubmitting, "replacement refusal releases the mutation lock")
check.expect(GiftRepository.getGiftCallCount == fetchesBeforeOverlap + 1, "replacement refusal authoritatively refetches")
registrationViewModel.registerTracking()
registrationViewModel.confirmCancel()
check.expect(GiftRepository.registerCallCount == 1, "completed registration cannot repeat its POST")
check.expect(GiftRepository.cancelCallCount == 0, "completed registration cannot start cancellation")
GiftRepository.reset()
GiftRepository.registerEvents = [.success(mutationFailure)]
let registrationFailureViewModel = loadValid()
registrationFailureViewModel.selectedCourier = "CJ대한통운"
registrationFailureViewModel.trackingNumber = "0012345678"
registrationFailureViewModel.registerTracking()
runMainLoop()
check.expect(!registrationFailureViewModel.isSubmitting, "registration failure releases the shared lock")
check.expect(registrationFailureViewModel.isShowPopup, "registration failure keeps the existing error toast")
check.expect(registrationFailureViewModel.selectedCourier == "CJ대한통운", "registration failure preserves courier input")
check.expect(registrationFailureViewModel.trackingNumber == "0012345678", "registration failure preserves tracking input")
check.expect(registrationFailureViewModel.summary != nil && registrationFailureViewModel.mailbox != nil, "registration failure preserves loaded display")
check.expect(registrationFailureViewModel.canSubmit, "registration failure restores submit eligibility")
GiftRepository.reset()
var cancellationRoutes: [String] = []
var cancellationCompletions: [(Bool) -> Void] = []
GiftRepository.cancelEvents = [.success(mutationSuccess)]
let cancellationViewModel = loadValid(onMutationCompleted: { applicationNo, completion in
cancellationRoutes.append(applicationNo)
cancellationCompletions.append(completion)
})
cancellationViewModel.confirmCancel()
runMainLoop()
check.expect(GiftRepository.cancelCallCount == 1, "cancellation calls the repository once")
check.expect(cancellationRoutes == ["GIFT-001"], "cancellation navigates immediately on success")
check.expect(!cancellationViewModel.isShowSuccessToast, "cancellation does not show registration toast")
check.expect(cancellationViewModel.isSubmitting, "cancellation retains the shared lock until navigation resolves")
cancellationViewModel.confirmCancel()
check.expect(GiftRepository.cancelCallCount == 1, "completed cancellation cannot repeat its POST")
GiftRepository.getGiftEvents = [.success(validDetail)]
cancellationCompletions[0](false)
runMainLoop()
check.expect(!cancellationViewModel.isSubmitting, "cancellation replacement refusal releases the lock")
check.expect(GiftRepository.getGiftCallCount == 2, "cancellation replacement refusal refetches exactly once")
GiftRepository.reset()
GiftRepository.cancelEvents = [.failure(response("{\"message\":\"cancel failed\"}"))]
let cancellationFailureViewModel = loadValid()
cancellationFailureViewModel.selectedCourier = "CJ대한통운"
cancellationFailureViewModel.trackingNumber = "0009"
cancellationFailureViewModel.confirmCancel()
runMainLoop()
check.expect(!cancellationFailureViewModel.isSubmitting, "cancellation transport failure releases the lock")
check.expect(cancellationFailureViewModel.isShowPopup, "cancellation transport failure keeps the error toast")
check.expect(cancellationFailureViewModel.selectedCourier == "CJ대한통운", "cancellation failure preserves courier input")
check.expect(cancellationFailureViewModel.trackingNumber == "0009", "cancellation failure preserves tracking input")
check.expect(cancellationFailureViewModel.summary != nil && cancellationFailureViewModel.mailbox != nil, "cancellation failure preserves loaded display")
print("PASS GiftTrackingRegistrationCheck assertions=\(check.assertions)")
}
}