feat(gift): 운송장 등록을 추가한다
This commit is contained in:
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user