From f57487db22dced6a82a77ea60bfef958521aa9a0 Mon Sep 17 00:00:00 2001 From: mosliem Date: Mon, 9 Feb 2026 01:29:34 +0200 Subject: [PATCH 01/10] fix: Handle OTP error codes returned with `HTTP 200` responses --- .../Core/Models/OTP/ErrorResponse.swift | 46 +++++++++++++++++++ .../ViewModel/TripPlannerViewModel.swift | 25 ++++++---- .../Resources/en.lproj/Localizable.strings | 27 +++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift index 7f7e9c4..8109b35 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift @@ -25,4 +25,50 @@ public struct ErrorResponse: Codable, Hashable { /// A descriptive message associated with the error, providing more detailed information about what went wrong. /// This message can be presented to the user or used in debugging to provide context about the error. public let message: String + + /// A message key identifying the error type. + /// This value maps directly to a key in `Messages.properties` and can be used + /// for control flow, localization, or client-side error mapping. + public let messageCode: ErrorResponseCode + + public enum CodingKeys: String, CodingKey { + case id + case message = "msg" + case messageCode = "message" + } +} + +public enum ErrorResponseCode: String, Codable, Hashable { + case systemError = "SYSTEM_ERROR" + case graphUnavailable = "GRAPH_UNAVAILABLE" + case outsideBounds = "OUTSIDE_BOUNDS" + case pathNotFound = "PATH_NOT_FOUND" + case noTransitTimes = "NO_TRANSIT_TIMES" + case requestTimeout = "REQUEST_TIMEOUT" + case bogusParameter = "BOGUS_PARAMETER" + case geocodeFromNotFound = "GEOCODE_FROM_NOT_FOUND" + case geocodeToNotFound = "GEOCODE_TO_NOT_FOUND" + case geocodeFromToNotFound = "GEOCODE_FROM_TO_NOT_FOUND" + case tooClose = "TOO_CLOSE" + case locationNotAccessible = "LOCATION_NOT_ACCESSIBLE" + case geocodeFromAmbiguous = "GEOCODE_FROM_AMBIGUOUS" + case geocodeToAmbiguous = "GEOCODE_TO_AMBIGUOUS" + case geocodeFromToAmbiguous = "GEOCODE_FROM_TO_AMBIGUOUS" + case underspecifiedTriangle = "UNDERSPECIFIED_TRIANGLE" + case triangleNotAffine = "TRIANGLE_NOT_AFFINE" + case triangleOptimizeTypeNotSet = "TRIANGLE_OPTIMIZE_TYPE_NOT_SET" + case triangleValuesNotSet = "TRIANGLE_VALUES_NOT_SET" + + /// Fallback for unknown or future OTP message keys + case unknown + + /// Localization key used to resolve a user-facing message + public var displayMessage: String { + switch self { + case .unknown: + return OTPLoc("error.unknown") + default: + return OTPLoc("error.\(rawValue.lowercased())") + } + } } diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift index 6d78a28..bc7f09b 100644 --- a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift @@ -177,7 +177,7 @@ public class TripPlannerViewModel: ObservableObject { func planTrip() { // Validate that we have required locations guard let origin = selectedOrigin, let destination = selectedDestination else { - showError(OTPKitError.missingOriginOrDestination) + showError(OTPKitError.missingOriginOrDestination.displayMessage) return } @@ -205,7 +205,7 @@ public class TripPlannerViewModel: ObservableObject { do { let response = try await apiService.fetchPlan(request) await MainActor.run { - self.handleSuccess(response) + self.handlePlanResponse(response) } } catch { await MainActor.run { @@ -246,20 +246,27 @@ public class TripPlannerViewModel: ObservableObject { } } - private func handleSuccess(_ response: OTPResponse) { - tripPlanResponse = response + private func handlePlanResponse(_ response: OTPResponse) { isLoading = false - HapticManager.shared.success() - notificationCenter.post(name: Notifications.itinerariesUpdated, object: nil) + tripPlanResponse = response + + if let error = response.error { + Logger.main.error("Planner Error: \(error.message)") + Logger.main.error("Planner Error Code: \(error.messageCode.rawValue)") + showError(error.messageCode.displayMessage) + } else { + HapticManager.shared.success() + notificationCenter.post(name: Notifications.itinerariesUpdated, object: nil) + } } private func handleError(_ error: Error) { let otpError = error as? OTPKitError ?? OTPKitError.tripPlanningFailed(error.localizedDescription) - showError(otpError) + showError(otpError.displayMessage) } - private func showError(_ error: OTPKitError) { - errorMessage = error.displayMessage + private func showError(_ message: String) { + errorMessage = message showingError = true isLoading = false } diff --git a/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings b/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings index d27e444..c00001c 100644 --- a/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings +++ b/OTPKit/Sources/OTPKit/Resources/en.lproj/Localizable.strings @@ -16,6 +16,33 @@ "error.missing_locations.message" = "Please select both origin and destination locations."; "error.unknown" = "An unknown error occurred."; +/* Planning */ +"error.system_error" = "We couldn’t plan your trip right now. Please try again."; +"error.graph_unavailable" = "Trip planning is temporarily unavailable. Try again shortly."; +"error.outside_bounds" = "This location isn’t supported yet."; +"error.path_not_found" = "No route is available for this trip."; +"error.no_transit_times" = "No transit options are available right now."; +"error.request_timeout" = "This took longer than expected. Please try again."; +"error.bogus_parameter" = "Something went wrong with the request. Please try again."; + +/* Geocoding */ +"error.geocode_from_not_found" = "We couldn’t find the starting location."; +"error.geocode_to_not_found" = "We couldn’t find the destination."; +"error.geocode_from_to_not_found" = "We couldn’t find either the starting point or the destination."; +"error.geocode_from_ambiguous" = "The starting location isn’t clear. Try being more specific."; +"error.geocode_to_ambiguous" = "The destination isn’t clear. Try being more specific."; +"error.geocode_from_to_ambiguous" = "Both locations need more detail. Try refining them."; + +/* Validation */ +"error.too_close" = "The start and destination are too close."; +"error.location_not_accessible" = "This route isn’t accessible with the selected option."; + +/* Triangle routing (advanced) */ +"error.underspecified_triangle" = "Some route options are missing."; +"error.triangle_not_affine" = "The selected route options don’t work together."; +"error.triangle_optimize_type_not_set" = "Please choose a route preference."; +"error.triangle_values_not_set" = "Please adjust the route preferences."; + /* Map */ "map.origin" = "Origin"; "map.destination" = "Destination"; From 742356652a7d5925fe7062fac16de257f0ac2138 Mon Sep 17 00:00:00 2001 From: mosliem Date: Mon, 9 Feb 2026 01:57:14 +0200 Subject: [PATCH 02/10] (TripPlannerViewModel) Disable file length rule. --- .../OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift index bc7f09b..e293b95 100644 --- a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift @@ -11,6 +11,7 @@ import MapKit import SwiftUI import OSLog +// swiftlint:disable file_length /// Main view model for handling trip planning functionality /// Manages location selection, transport modes, API calls, and UI state @MainActor @@ -402,3 +403,4 @@ public class TripPlannerViewModel: ObservableObject { activeSheet = nil } } +// swiftlint:enable file_length From ca664028490fef52d4f9a8fbee1a8502f5cda648 Mon Sep 17 00:00:00 2001 From: mosliem Date: Mon, 9 Feb 2026 02:08:44 +0200 Subject: [PATCH 03/10] (ErrorResponseCode): Handle OTP error responses with non-enum message values. --- .../OTPKit/Core/Models/OTP/ErrorResponse.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift index 8109b35..800da71 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift @@ -62,6 +62,18 @@ public enum ErrorResponseCode: String, Codable, Hashable { /// Fallback for unknown or future OTP message keys case unknown + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + self = ErrorResponseCode(rawValue: value) ?? .unknown + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + /// Localization key used to resolve a user-facing message public var displayMessage: String { switch self { From ecedf84acea67df66dcb563ea263c1588e389a74 Mon Sep 17 00:00:00 2001 From: mosliem Date: Mon, 9 Feb 2026 02:09:28 +0200 Subject: [PATCH 04/10] (RestAPIServiceComprehensiveTests): Fix expected error message value. --- OTPKit/Tests/RestAPIServiceComprehensiveTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift b/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift index 60dac88..7a9db1d 100644 --- a/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift +++ b/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift @@ -313,7 +313,7 @@ struct RestAPIServiceComprehensiveTests { #expect(response.plan == nil) #expect(response.error != nil) - #expect(response.error?.message == "No trip found that satisfies the requested parameters.") + #expect(response.error?.message == "Trip not found") } // MARK: - HTTP Error Tests From 66a82dc39126f9b70c654f3daced4fc2735a8005 Mon Sep 17 00:00:00 2001 From: mosliem Date: Sat, 14 Feb 2026 00:55:58 +0200 Subject: [PATCH 05/10] Update: (ErrorResponse) Add logging for unknown OTP error codes and enforce exhaustive localization handling. --- .../Core/Models/OTP/ErrorResponse.swift | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift b/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift index 800da71..42c8b26 100644 --- a/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift +++ b/OTPKit/Sources/OTPKit/Core/Models/OTP/ErrorResponse.swift @@ -15,6 +15,7 @@ */ import Foundation +import OSLog /// `ErrorResponse` represents an error structure used across the application to handle and represent /// OTP errors uniformly. @@ -26,7 +27,7 @@ public struct ErrorResponse: Codable, Hashable { /// This message can be presented to the user or used in debugging to provide context about the error. public let message: String - /// A message key identifying the error type. + /// A message key identifying the error type. /// This value maps directly to a key in `Messages.properties` and can be used /// for control flow, localization, or client-side error mapping. public let messageCode: ErrorResponseCode @@ -66,7 +67,12 @@ public enum ErrorResponseCode: String, Codable, Hashable { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) - self = ErrorResponseCode(rawValue: value) ?? .unknown + if let known = ErrorResponseCode(rawValue: value) { + self = known + } else { + Logger.main.warning("Unrecognized OTP error code: \(value)") + self = .unknown + } } public func encode(to encoder: Encoder) throws { @@ -77,10 +83,46 @@ public enum ErrorResponseCode: String, Codable, Hashable { /// Localization key used to resolve a user-facing message public var displayMessage: String { switch self { + case .systemError: + return OTPLoc("error.system_error") + case .graphUnavailable: + return OTPLoc("error.graph_unavailable") + case .outsideBounds: + return OTPLoc("error.outside_bounds") + case .pathNotFound: + return OTPLoc("error.path_not_found") + case .noTransitTimes: + return OTPLoc("error.no_transit_times") + case .requestTimeout: + return OTPLoc("error.request_timeout") + case .bogusParameter: + return OTPLoc("error.bogus_parameter") + case .geocodeFromNotFound: + return OTPLoc("error.geocode_from_not_found") + case .geocodeToNotFound: + return OTPLoc("error.geocode_to_not_found") + case .geocodeFromToNotFound: + return OTPLoc("error.geocode_from_to_not_found") + case .tooClose: + return OTPLoc("error.too_close") + case .locationNotAccessible: + return OTPLoc("error.location_not_accessible") + case .geocodeFromAmbiguous: + return OTPLoc("error.geocode_from_ambiguous") + case .geocodeToAmbiguous: + return OTPLoc("error.geocode_to_ambiguous") + case .geocodeFromToAmbiguous: + return OTPLoc("error.geocode_from_to_ambiguous") + case .underspecifiedTriangle: + return OTPLoc("error.underspecified_triangle") + case .triangleNotAffine: + return OTPLoc("error.triangle_not_affine") + case .triangleOptimizeTypeNotSet: + return OTPLoc("error.triangle_optimize_type_not_set") + case .triangleValuesNotSet: + return OTPLoc("error.triangle_values_not_set") case .unknown: return OTPLoc("error.unknown") - default: - return OTPLoc("error.\(rawValue.lowercased())") } } } From b50010e52eda14eeb4a632ac2221e01238614b6b Mon Sep 17 00:00:00 2001 From: mosliem Date: Sat, 14 Feb 2026 00:57:01 +0200 Subject: [PATCH 06/10] Update: (TripPlannerViewModel) Update handling plan response and loading states. --- .../Presentation/ViewModel/TripPlannerViewModel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift index e293b95..609093f 100644 --- a/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift +++ b/OTPKit/Sources/OTPKit/Presentation/ViewModel/TripPlannerViewModel.swift @@ -249,13 +249,15 @@ public class TripPlannerViewModel: ObservableObject { private func handlePlanResponse(_ response: OTPResponse) { isLoading = false - tripPlanResponse = response if let error = response.error { Logger.main.error("Planner Error: \(error.message)") Logger.main.error("Planner Error Code: \(error.messageCode.rawValue)") + + tripPlanResponse = nil showError(error.messageCode.displayMessage) } else { + tripPlanResponse = response HapticManager.shared.success() notificationCenter.post(name: Notifications.itinerariesUpdated, object: nil) } @@ -263,13 +265,13 @@ public class TripPlannerViewModel: ObservableObject { private func handleError(_ error: Error) { let otpError = error as? OTPKitError ?? OTPKitError.tripPlanningFailed(error.localizedDescription) + isLoading = false showError(otpError.displayMessage) } private func showError(_ message: String) { errorMessage = message showingError = true - isLoading = false } // MARK: - Preview Management From ef9b969bf98014f382285741fa05a44b0353cbd4 Mon Sep 17 00:00:00 2001 From: mosliem Date: Sat, 14 Feb 2026 00:58:16 +0200 Subject: [PATCH 07/10] Add: (RestAPIServiceComprehnsiveTests) Add test case for OTP unknown error handling. --- OTPKit/Tests/Fixtures/otp_response_error.json | 2 +- .../Fixtures/otp_response_unknown_error.json | 20 ++++++++++++++ .../RestAPIServiceComprehensiveTests.swift | 27 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 OTPKit/Tests/Fixtures/otp_response_unknown_error.json diff --git a/OTPKit/Tests/Fixtures/otp_response_error.json b/OTPKit/Tests/Fixtures/otp_response_error.json index 22efe1e..ec4ec4d 100644 --- a/OTPKit/Tests/Fixtures/otp_response_error.json +++ b/OTPKit/Tests/Fixtures/otp_response_error.json @@ -13,7 +13,7 @@ "error": { "id": 404, "msg": "Trip not found", - "message": "No trip found that satisfies the requested parameters.", + "message": "PATH_NOT_FOUND", "missing": [], "noPath": true } diff --git a/OTPKit/Tests/Fixtures/otp_response_unknown_error.json b/OTPKit/Tests/Fixtures/otp_response_unknown_error.json new file mode 100644 index 0000000..c61f26f --- /dev/null +++ b/OTPKit/Tests/Fixtures/otp_response_unknown_error.json @@ -0,0 +1,20 @@ +{ + "requestParameters": { + "fromPlace": "47.6097,-122.3331", + "toPlace": "47.6154,-122.3208", + "time": "08:00:00", + "date": "2024-05-10", + "mode": "TRANSIT,WALK", + "arriveBy": "false", + "maxWalkDistance": "800", + "wheelchair": "false" + }, + "plan": null, + "error": { + "id": 404, + "msg": "Trip not found", + "message": "Unknown Error Type", + "missing": [], + "noPath": true + } +} diff --git a/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift b/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift index 7a9db1d..b1ab0d7 100644 --- a/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift +++ b/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift @@ -314,8 +314,35 @@ struct RestAPIServiceComprehensiveTests { #expect(response.plan == nil) #expect(response.error != nil) #expect(response.error?.message == "Trip not found") + #expect(response.error?.messageCode == .pathNotFound) } + @Test("fetchPlan with OTP unknown error response") + func fetchPlanWithOTPUnknownErrorResponse() async throws { + let mockLoader = MockDataLoader() + let fixtureData = Fixtures.loadData(file: "otp_response_unknown_error.json") + mockLoader.mockResponse(data: fixtureData) + + let service = RestAPIService( + baseURL: URL(string: "https://otp.example.com")!, + dataLoader: mockLoader + ) + + let request = TripPlanRequest( + origin: CLLocationCoordinate2D(latitude: 47.6097, longitude: -122.3331), + destination: CLLocationCoordinate2D(latitude: 47.6154, longitude: -122.3208), + date: Date(), + time: Date() + ) + + let response = try await service.fetchPlan(request) + + #expect(response.plan == nil) + #expect(response.error != nil) + #expect(response.error?.message == "Trip not found") + #expect(response.error?.messageCode == .unknown) + } + // MARK: - HTTP Error Tests @Test("fetchPlan throws on HTTP 404") From b27518d407657299705283ae2986724499db721a Mon Sep 17 00:00:00 2001 From: mosliem Date: Sat, 14 Feb 2026 01:00:13 +0200 Subject: [PATCH 08/10] Add: (TripPlannerViewModelTests) Add tests for HTTP 200 error-response handling. --- OTPKit/Tests/Helpers/TestHelpers.swift | 4 +- OTPKit/Tests/TripPlannerViewModelTests.swift | 121 +++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/OTPKit/Tests/Helpers/TestHelpers.swift b/OTPKit/Tests/Helpers/TestHelpers.swift index 56c5cc0..f354a0e 100644 --- a/OTPKit/Tests/Helpers/TestHelpers.swift +++ b/OTPKit/Tests/Helpers/TestHelpers.swift @@ -68,7 +68,7 @@ enum TestHelpers { } /// Wrap itineraries into an `OTPResponse` the view model expects - static func response(with itineraries: [Itinerary]) -> OTPResponse { + static func response(with itineraries: [Itinerary] = [], error: ErrorResponse? = nil) -> OTPResponse { let plan = Plan( date: Date(), from: Place(name: "A", lon: -122.0, lat: 47.0, vertexType: "NORMAL"), @@ -85,6 +85,6 @@ enum TestHelpers { maxWalkDistance: "1000", wheelchair: "false" ) - return OTPResponse(requestParameters: params, plan: plan, error: nil) + return OTPResponse(requestParameters: params, plan: plan, error: error) } } diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index 0cd8296..374bc24 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -453,6 +453,127 @@ struct TripPlannerViewModelTests { #expect(viewModel.activeSheet != nil) } + // MARK: - Plan Error Response Handling Test + @Test("planTrip handles OTP error in successful HTTP response") + func planTripHandlesOTPErrorInSuccessfulResponse() async throws { + let mockAPIService = TestFixtures.MockAPIService() + + let errorResponse = TestHelpers.response(error: .init(id: 1, message: "Path not found", messageCode: .pathNotFound)) + mockAPIService.mockResponse = errorResponse + + let viewModel = createViewModel(mockAPIService: mockAPIService) + viewModel.selectedOrigin = TestHelpers.location(title: "Origin") + viewModel.selectedDestination = TestHelpers.location(title: "Destination") + + viewModel.planTrip() + + try await waitForLoadingComplete(viewModel) + + #expect(viewModel.showingError == true) + #expect(viewModel.errorMessage != nil) + #expect(viewModel.isLoading == false) + #expect(viewModel.tripPlanResponse == nil) + } + + @Test("planTrip error response clears previous successful trip plan") + func planTripErrorResponseClearsPreviousSuccessfulPlan() async throws { + let mockAPIService = TestFixtures.MockAPIService() + + let viewModel = createViewModel(mockAPIService: mockAPIService) + viewModel.selectedOrigin = TestHelpers.location(title: "Origin") + viewModel.selectedDestination = TestHelpers.location(title: "Destination") + + // First call: successful response with itineraries + let successResponse = TestHelpers.response(with: [TestHelpers.itinerary()]) + mockAPIService.mockResponse = successResponse + + viewModel.planTrip() + try await waitForLoadingComplete(viewModel) + + #expect(viewModel.tripPlanResponse != nil) + #expect(viewModel.showingError == false) + + // Second call: error response + let errorResponse = TestHelpers.response( + error: .init( + id: 1, + message: "Path not found", + messageCode: .pathNotFound + ) + ) + + mockAPIService.mockResponse = errorResponse + + viewModel.planTrip() + try await waitForLoadingComplete(viewModel) + + // Verify previous response is cleared and error is shown + #expect(viewModel.tripPlanResponse == nil) + #expect(viewModel.showingError == true) + #expect(viewModel.errorMessage != nil) + #expect(viewModel.isLoading == false) + } + + @Test("planTrip handles PATH_NOT_FOUND error code with correct message") + func planTripHandlesPathNotFoundError() async throws { + let mockAPIService = TestFixtures.MockAPIService() + + let errorResponse = TestHelpers.response(error: .init(id: 1, message: "Path not found", messageCode: .pathNotFound)) + mockAPIService.mockResponse = errorResponse + + let viewModel = createViewModel(mockAPIService: mockAPIService) + viewModel.selectedOrigin = TestHelpers.location(title: "Origin") + viewModel.selectedDestination = TestHelpers.location(title: "Destination") + + viewModel.planTrip() + try await waitForLoadingComplete(viewModel) + + #expect(viewModel.showingError == true) + #expect(viewModel.errorMessage == errorResponse.error?.messageCode.displayMessage) + #expect(viewModel.isLoading == false) + #expect(viewModel.tripPlanResponse == nil) + } + + @Test("planTrip differentiates between OTP errors and network errors") + func planTripDistinguishesBetweenOTPAndNetworkErrors() async throws { + let mockAPIService = TestFixtures.MockAPIService() + let viewModel = createViewModel(mockAPIService: mockAPIService) + viewModel.selectedOrigin = TestHelpers.location(title: "Origin") + viewModel.selectedDestination = TestHelpers.location(title: "Destination") + + // Test 1: Network error + mockAPIService.shouldThrowError = true + mockAPIService.mockError = OTPKitError.apiError("Network timeout") + + viewModel.planTrip() + try await waitForLoadingComplete(viewModel) + + let networkErrorMessage = viewModel.errorMessage + #expect(viewModel.showingError == true) + #expect(networkErrorMessage != nil) + #expect(viewModel.isLoading == false) + + // Reset error state + viewModel.errorMessage = nil + viewModel.showingError = false + + // Test 2: OTP error + mockAPIService.shouldThrowError = false + let errorResponse = TestHelpers.response(error: .init(id: 1, message: "Path not found", messageCode: .pathNotFound)) + mockAPIService.mockResponse = errorResponse + + viewModel.planTrip() + try await waitForLoadingComplete(viewModel) + + let otpErrorMessage = viewModel.errorMessage + #expect(viewModel.showingError == true) + #expect(otpErrorMessage != nil) + #expect(viewModel.isLoading == false) + + + #expect(networkErrorMessage != otpErrorMessage) + } + // MARK: - Reset Tests @Test("resetTripPlanner clears all state") From 8ca1a345f79b3d0d147723b43af193d475676c2a Mon Sep 17 00:00:00 2001 From: mosliem Date: Sat, 14 Feb 2026 01:02:05 +0200 Subject: [PATCH 09/10] Fix: SwiftLints warnings. --- OTPKit/Tests/RestAPIServiceComprehensiveTests.swift | 2 +- OTPKit/Tests/TripPlannerViewModelTests.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift b/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift index b1ab0d7..456ed2a 100644 --- a/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift +++ b/OTPKit/Tests/RestAPIServiceComprehensiveTests.swift @@ -342,7 +342,7 @@ struct RestAPIServiceComprehensiveTests { #expect(response.error?.message == "Trip not found") #expect(response.error?.messageCode == .unknown) } - + // MARK: - HTTP Error Tests @Test("fetchPlan throws on HTTP 404") diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index 374bc24..2405aba 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -570,7 +570,6 @@ struct TripPlannerViewModelTests { #expect(otpErrorMessage != nil) #expect(viewModel.isLoading == false) - #expect(networkErrorMessage != otpErrorMessage) } From afcae22ff2dfa22fbd7460f33e2e231305499b5d Mon Sep 17 00:00:00 2001 From: mosliem Date: Sat, 14 Feb 2026 01:44:08 +0200 Subject: [PATCH 10/10] Update: (TripPlannerViewModelTests) Update polling value in `waitForLoadingComplete` helper method. --- OTPKit/Tests/TripPlannerViewModelTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OTPKit/Tests/TripPlannerViewModelTests.swift b/OTPKit/Tests/TripPlannerViewModelTests.swift index 2405aba..0b9d33d 100644 --- a/OTPKit/Tests/TripPlannerViewModelTests.swift +++ b/OTPKit/Tests/TripPlannerViewModelTests.swift @@ -33,7 +33,7 @@ struct TripPlannerViewModelTests { ) async throws { let start = Date() while viewModel.isLoading && Date().timeIntervalSince(start) < timeout { - try await Task.sleep(nanoseconds: 50_000_000) // Poll every 0.05 seconds + try await Task.sleep(nanoseconds: 100_000_000) // Poll every 0.1 seconds } }