diff --git a/Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift b/Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift new file mode 100644 index 0000000..626862e --- /dev/null +++ b/Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift @@ -0,0 +1,20 @@ +import Domain +import Foundation + +public struct AlarmRefreshResponseDTO: Decodable, Sendable { + public let departureTime: String? + public let updatedAt: String? + public let lastRouteId: String? + // 서버가 Bool이 아니라 "true"/"false" 문자열로 준다 (레거시 실측). + public let isReal: String? + + public func toEntity() -> AlarmInfo? { + guard let lastRouteId else { return nil } + return AlarmInfo( + lastRouteId: lastRouteId, + departureTime: departureTime.flatMap { ServerDateParser.date(from: $0) }, + updatedAt: updatedAt.flatMap { ServerDateParser.date(from: $0) }, + isReal: isReal == "true" + ) + } +} diff --git a/Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift b/Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift new file mode 100644 index 0000000..ac59955 --- /dev/null +++ b/Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift @@ -0,0 +1,7 @@ +public struct AlarmRegisterRequestDTO: Encodable, Sendable { + public let lastRouteId: String + + public init(lastRouteId: String) { + self.lastRouteId = lastRouteId + } +} diff --git a/Projects/Data/Sources/DTO/LastRouteResponseDTO.swift b/Projects/Data/Sources/DTO/LastRouteResponseDTO.swift new file mode 100644 index 0000000..9560831 --- /dev/null +++ b/Projects/Data/Sources/DTO/LastRouteResponseDTO.swift @@ -0,0 +1,87 @@ +import Domain +import Foundation + +public struct LastRouteResponseDTO: Decodable, Sendable { + public let routeId: String? + public let departureDateTime: String? + public let totalTime: Int? + public let totalWalkTime: Int? + public let transferCount: Int? + public let totalDistance: Int? + public let totalWalkDistance: Int? + public let legs: [LegResponseDTO]? + + /// routeId·막차 출발 시각이 없는 항목은 세울 수 없어 nil을 돌려준다 (호출부 compactMap). + public func toEntity() -> LastRoute? { + guard let routeId, + let departureDateTime, + let departureTime = ServerDateParser.date(from: departureDateTime) + else { return nil } + return LastRoute( + id: routeId, + departureTime: departureTime, + totalTime: totalTime ?? 0, + totalWalkTime: totalWalkTime ?? 0, + transferCount: transferCount ?? 0, + totalDistance: totalDistance ?? 0, + totalWalkDistance: totalWalkDistance ?? 0, + legs: legs?.map { $0.toEntity() } ?? [] + ) + } +} + +// 지도 표시 전용 필드(passStopList/step/passShape 등)는 2.0 스코프에 없어 디코딩하지 않는다. +public struct LegResponseDTO: Decodable, Sendable { + public let distance: Int? + public let sectionTime: Int? + // 레거시는 enum으로 받아 미지의 mode 문자열에서 디코딩이 통째로 실패했다 — String으로 받고 매핑한다. + public let mode: String? + public let departureDateTime: String? + public let route: String? + public let type: String? + public let start: RoutePointResponseDTO? + public let end: RoutePointResponseDTO? + public let subwayFinalStation: String? + public let subwayDirection: String? + public let isExpressSubway: Bool? + public let isLastSubway: Bool? + + public func toEntity() -> TransportLeg { + TransportLeg( + mode: TransportMode(serverValue: mode), + sectionTime: sectionTime ?? 0, + distance: distance ?? 0, + departureTime: departureDateTime.flatMap { ServerDateParser.date(from: $0) }, + routeName: route, + lineType: type, + start: start?.toEntity(), + end: end?.toEntity(), + subwayFinalStation: subwayFinalStation, + subwayDirection: subwayDirection, + isExpressSubway: isExpressSubway ?? false, + isLastSubway: isLastSubway ?? false + ) + } +} + +public struct RoutePointResponseDTO: Decodable, Sendable { + public let name: String? + public let lon: Double? + public let lat: Double? + + public func toEntity() -> RoutePoint? { + guard let name, let lat, let lon else { return nil } + return RoutePoint(name: name, coordinate: Coordinate(latitude: lat, longitude: lon)) + } +} + +private extension TransportMode { + init(serverValue: String?) { + switch serverValue { + case "WALK": self = .walk + case "BUS": self = .bus + case "SUBWAY": self = .subway + default: self = .unknown + } + } +} diff --git a/Projects/Data/Sources/DTO/PlaceResponseDTO.swift b/Projects/Data/Sources/DTO/PlaceResponseDTO.swift new file mode 100644 index 0000000..e213b6c --- /dev/null +++ b/Projects/Data/Sources/DTO/PlaceResponseDTO.swift @@ -0,0 +1,16 @@ +import Domain + +public struct PlaceResponseDTO: Decodable, Sendable { + public let name: String? + public let lat: Double? + public let lon: Double? + public let businessCategory: String? + public let address: String? + public let radius: String? + + /// 레거시는 6필드 전부 non-nil이어야 항목을 살렸지만, 이름·좌표만 있으면 표시엔 충분하다. + public func toEntity() -> Place? { + guard let name, let lat, let lon else { return nil } + return Place(name: name, address: address ?? "", coordinate: Coordinate(latitude: lat, longitude: lon)) + } +} diff --git a/Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift b/Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift new file mode 100644 index 0000000..a1aa2a6 --- /dev/null +++ b/Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift @@ -0,0 +1,13 @@ +import Domain + +public struct ReverseGeocodeResponseDTO: Decodable, Sendable { + public let name: String? + public let address: String? + public let lat: Double? + public let lon: Double? + + public func toEntity() -> Place? { + guard let name, let lat, let lon else { return nil } + return Place(name: name, address: address ?? "", coordinate: Coordinate(latitude: lat, longitude: lon)) + } +} diff --git a/Projects/Data/Sources/Network/APIResponse.swift b/Projects/Data/Sources/Network/APIResponse.swift new file mode 100644 index 0000000..d2a747f --- /dev/null +++ b/Projects/Data/Sources/Network/APIResponse.swift @@ -0,0 +1,17 @@ +// 레거시 서버 envelope 실측: { "responseCode": "SUCCESS", "result": ... } +struct APIResponse: Decodable, Sendable { + let responseCode: String + let result: T? +} + +/// result가 없는(무시되는) 응답을 기대할 때 쓰는 자리표시 타입. +struct APIEmptyResult: Decodable, Sendable {} + +/// 실패 응답 본문 실측: { "responseCode": ..., "message": ..., "path": ... } +struct APIFailureResponse: Decodable, Sendable { + let responseCode: String? + let message: String? +} + +/// envelope는 성공인데 기대한 result가 없거나 엔티티로 세울 수 없을 때. +struct MissingResultError: Error, Sendable {} diff --git a/Projects/Data/Sources/Network/AlarmEndpoint.swift b/Projects/Data/Sources/Network/AlarmEndpoint.swift new file mode 100644 index 0000000..9f2ce97 --- /dev/null +++ b/Projects/Data/Sources/Network/AlarmEndpoint.swift @@ -0,0 +1,46 @@ +import CoreNetwork +import Foundation + +enum AlarmEndpoint: Endpoint { + case register(AlarmRegisterRequestDTO) + case cancel(lastRouteId: String) + case refresh + + var path: String { + switch self { + case .register, .cancel: "/routes/user-routes" + case .refresh: "/routes/user-routes/refresh" + } + } + + var method: HTTPMethod { + switch self { + case .register: .post + case .cancel: .delete + case .refresh: .get + } + } + + var headers: [String: String] { + switch self { + case .register: ["Content-Type": "application/json"] + case .cancel, .refresh: [:] + } + } + + var queryItems: [URLQueryItem] { + switch self { + // 삭제는 body가 아니라 쿼리로 lastRouteId를 받는다 (레거시 실측). + case let .cancel(lastRouteId): + [URLQueryItem(name: "lastRouteId", value: lastRouteId)] + case .register, .refresh: [] + } + } + + var body: Data? { + switch self { + case let .register(request): try? JSONEncoder().encode(request) + case .cancel, .refresh: nil + } + } +} diff --git a/Projects/Data/Sources/Network/NetworkClient+Envelope.swift b/Projects/Data/Sources/Network/NetworkClient+Envelope.swift new file mode 100644 index 0000000..991d0e8 --- /dev/null +++ b/Projects/Data/Sources/Network/NetworkClient+Envelope.swift @@ -0,0 +1,46 @@ +import CoreNetwork +import Domain +import Foundation + +private let successResponseCode = "SUCCESS" + +extension NetworkClient { + /// envelope를 해체해 result만 돌려준다. responseCode ≠ SUCCESS면 `ServerError`. + func requestEnveloped( + _ endpoint: any Endpoint, + as _: T.Type = T.self + ) async throws -> T { + let data: Data + do { + data = try await self.data(for: endpoint) + } catch let error as NetworkError { + // 비즈니스 에러 코드는 non-2xx HTTP의 body envelope로 온다 (레거시 실측). + throw serverError(from: error) ?? error + } + + let envelope: APIResponse + do { + envelope = try JSONDecoder().decode(APIResponse.self, from: data) + } catch { + // 레거시 규약: 2xx + 빈 응답 기대(T == APIEmptyResult)면 본문 형태와 무관하게 성공. + if let empty = APIEmptyResult() as? T { return empty } + throw NetworkError.decoding(underlying: error) + } + + guard envelope.responseCode == successResponseCode else { + let message = (try? JSONDecoder().decode(APIFailureResponse.self, from: data))?.message + throw ServerError(code: envelope.responseCode, message: message) + } + if let result = envelope.result { return result } + if let empty = APIEmptyResult() as? T { return empty } + throw NetworkError.decoding(underlying: MissingResultError()) + } +} + +private func serverError(from error: NetworkError) -> ServerError? { + guard case let .unacceptableStatus(_, data) = error, + let failure = try? JSONDecoder().decode(APIFailureResponse.self, from: data), + let code = failure.responseCode + else { return nil } + return ServerError(code: code, message: failure.message) +} diff --git a/Projects/Data/Sources/Network/PlaceEndpoint.swift b/Projects/Data/Sources/Network/PlaceEndpoint.swift new file mode 100644 index 0000000..443fa78 --- /dev/null +++ b/Projects/Data/Sources/Network/PlaceEndpoint.swift @@ -0,0 +1,38 @@ +import CoreNetwork +import Domain +import Foundation + +enum PlaceEndpoint: Endpoint { + case search(keyword: String, near: Coordinate?) + case reverseGeocode(Coordinate) + + var path: String { + switch self { + case .search: "/locations" + case .reverseGeocode: "/locations/rgeo" + } + } + + var method: HTTPMethod { + switch self { + case .search, .reverseGeocode: .get + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .search(keyword, near): + // 좌표 미지정 시 0.0 전송은 레거시 실측 규약. + [ + URLQueryItem(name: "keyword", value: keyword), + URLQueryItem(name: "lat", value: String(near?.latitude ?? 0.0)), + URLQueryItem(name: "lon", value: String(near?.longitude ?? 0.0)), + ] + case let .reverseGeocode(coordinate): + [ + URLQueryItem(name: "lat", value: String(coordinate.latitude)), + URLQueryItem(name: "lon", value: String(coordinate.longitude)), + ] + } + } +} diff --git a/Projects/Data/Sources/Network/RouteEndpoint.swift b/Projects/Data/Sources/Network/RouteEndpoint.swift new file mode 100644 index 0000000..16b3d08 --- /dev/null +++ b/Projects/Data/Sources/Network/RouteEndpoint.swift @@ -0,0 +1,34 @@ +import CoreNetwork +import Domain +import Foundation + +enum RouteEndpoint: Endpoint { + case search(start: Coordinate, end: Coordinate) + case detail(routeId: String) + + var path: String { + switch self { + case .search: "/routes/last-routes" + case let .detail(routeId): "/routes/last-routes/\(routeId)" + } + } + + var method: HTTPMethod { + switch self { + case .search, .detail: .get + } + } + + var queryItems: [URLQueryItem] { + switch self { + case let .search(start, end): + [ + URLQueryItem(name: "startLat", value: String(start.latitude)), + URLQueryItem(name: "startLon", value: String(start.longitude)), + URLQueryItem(name: "endLat", value: String(end.latitude)), + URLQueryItem(name: "endLon", value: String(end.longitude)), + ] + case .detail: [] + } + } +} diff --git a/Projects/Data/Sources/Network/ServerDateParser.swift b/Projects/Data/Sources/Network/ServerDateParser.swift new file mode 100644 index 0000000..01a5700 --- /dev/null +++ b/Projects/Data/Sources/Network/ServerDateParser.swift @@ -0,0 +1,18 @@ +import Foundation + +/// 서버 시각은 타임존 표기 없는 KST 문자열이다 (실측: "yyyy-MM-dd'T'HH:mm:ss"). +enum ServerDateParser { + static func date(from string: String) -> Date? { + // DateFormatter는 Sendable이 아니므로 호출마다 새로 만든다. + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Seoul") + for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] { + formatter.dateFormat = format + if let date = formatter.date(from: string) { + return date + } + } + return nil + } +} diff --git a/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift new file mode 100644 index 0000000..b603210 --- /dev/null +++ b/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift @@ -0,0 +1,30 @@ +import CoreNetwork +import Domain + +public struct AlarmRepositoryImpl: AlarmRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func register(lastRouteId: String) async throws { + let _: APIEmptyResult = try await networkClient.requestEnveloped( + AlarmEndpoint.register(AlarmRegisterRequestDTO(lastRouteId: lastRouteId)) + ) + } + + public func cancel(lastRouteId: String) async throws { + let _: APIEmptyResult = try await networkClient.requestEnveloped( + AlarmEndpoint.cancel(lastRouteId: lastRouteId) + ) + } + + public func refresh() async throws -> AlarmInfo { + let dto: AlarmRefreshResponseDTO = try await networkClient.requestEnveloped(AlarmEndpoint.refresh) + guard let info = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return info + } +} diff --git a/Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift b/Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift new file mode 100644 index 0000000..21d605b --- /dev/null +++ b/Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift @@ -0,0 +1,27 @@ +import CoreNetwork +import Domain + +public struct LastRouteRepositoryImpl: LastRouteRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { + let dtos: [LastRouteResponseDTO] = try await networkClient.requestEnveloped( + RouteEndpoint.search(start: start, end: end) + ) + return dtos.compactMap { $0.toEntity() } + } + + public func lastRoute(id: String) async throws -> LastRoute { + let dto: LastRouteResponseDTO = try await networkClient.requestEnveloped( + RouteEndpoint.detail(routeId: id) + ) + guard let route = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return route + } +} diff --git a/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift b/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift new file mode 100644 index 0000000..62d8d84 --- /dev/null +++ b/Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift @@ -0,0 +1,27 @@ +import CoreNetwork +import Domain + +public struct PlaceRepositoryImpl: PlaceRepository { + private let networkClient: any NetworkClient + + public init(networkClient: any NetworkClient) { + self.networkClient = networkClient + } + + public func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + let dtos: [PlaceResponseDTO] = try await networkClient.requestEnveloped( + PlaceEndpoint.search(keyword: keyword, near: coordinate) + ) + return dtos.compactMap { $0.toEntity() } + } + + public func reverseGeocode(_ coordinate: Coordinate) async throws -> Place { + let dto: ReverseGeocodeResponseDTO = try await networkClient.requestEnveloped( + PlaceEndpoint.reverseGeocode(coordinate) + ) + guard let place = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return place + } +} diff --git a/Projects/Data/Tests/AlarmEndpointTests.swift b/Projects/Data/Tests/AlarmEndpointTests.swift new file mode 100644 index 0000000..f65db4f --- /dev/null +++ b/Projects/Data/Tests/AlarmEndpointTests.swift @@ -0,0 +1,36 @@ +@testable import AtchaData +import CoreNetwork +import Foundation +import Testing + +struct AlarmEndpointTests { + @Test + func register_postsJSONBodyWithLastRouteId() throws { + let endpoint = AlarmEndpoint.register(AlarmRegisterRequestDTO(lastRouteId: "route-1")) + #expect(endpoint.path == "/routes/user-routes") + #expect(endpoint.method == .post) + #expect(endpoint.headers == ["Content-Type": "application/json"]) + #expect(endpoint.queryItems.isEmpty) + let body = try #require(endpoint.body) + let json = try JSONSerialization.jsonObject(with: body) as? [String: String] + #expect(json == ["lastRouteId": "route-1"]) + } + + @Test + func cancel_usesQueryNotBodyLikeLegacy() { + let endpoint = AlarmEndpoint.cancel(lastRouteId: "route-1") + #expect(endpoint.path == "/routes/user-routes") + #expect(endpoint.method == .delete) + #expect(endpoint.queryItems == [URLQueryItem(name: "lastRouteId", value: "route-1")]) + #expect(endpoint.body == nil) + } + + @Test + func refresh_getsRefreshPath() { + let endpoint = AlarmEndpoint.refresh + #expect(endpoint.path == "/routes/user-routes/refresh") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems.isEmpty) + #expect(endpoint.body == nil) + } +} diff --git a/Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift b/Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift new file mode 100644 index 0000000..9a20ed4 --- /dev/null +++ b/Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift @@ -0,0 +1,33 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct AlarmRefreshResponseDTOTests { + @Test + func toEntity_mapsFieldsAndParsesIsRealString() throws { + let json = Data( + #"{"departureTime":"2026-08-22T23:40:00","updatedAt":"2026-08-22T22:00:00","lastRouteId":"route-1","isReal":"true"}"#.utf8 + ) + let dto = try JSONDecoder().decode(AlarmRefreshResponseDTO.self, from: json) + let info = try #require(dto.toEntity()) + #expect(info.lastRouteId == "route-1") + #expect(info.isReal) + #expect(info.departureTime != nil) + #expect(info.updatedAt != nil) + } + + @Test + func toEntity_isRealFalseOrMissing_mapsToFalse() throws { + for fixture in [#"{"lastRouteId":"r","isReal":"false"}"#, #"{"lastRouteId":"r"}"#] { + let dto = try JSONDecoder().decode(AlarmRefreshResponseDTO.self, from: Data(fixture.utf8)) + #expect(dto.toEntity()?.isReal == false) + } + } + + @Test + func toEntity_missingLastRouteId_returnsNil() throws { + let dto = try JSONDecoder().decode(AlarmRefreshResponseDTO.self, from: Data(#"{"isReal":"true"}"#.utf8)) + #expect(dto.toEntity() == nil) + } +} diff --git a/Projects/Data/Tests/EnvelopeTests.swift b/Projects/Data/Tests/EnvelopeTests.swift new file mode 100644 index 0000000..f86106d --- /dev/null +++ b/Projects/Data/Tests/EnvelopeTests.swift @@ -0,0 +1,84 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +private struct PingEndpoint: Endpoint { + var path: String { "/ping" } + var method: HTTPMethod { .get } +} + +private struct StubNetworkClient: NetworkClient { + let result: Result + + func data(for endpoint: any Endpoint) async throws -> Data { + try result.get() + } + + func request( + _ endpoint: any Endpoint, + as _: Response.Type + ) async throws -> Response { + try JSONDecoder().decode(Response.self, from: result.get()) + } +} + +struct EnvelopeTests { + @Test + func requestEnveloped_success_unwrapsResult() async throws { + let body = Data(#"{"responseCode":"SUCCESS","result":{"lastRouteId":"route-1","isReal":"true"}}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + let dto: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + #expect(dto.lastRouteId == "route-1") + #expect(dto.isReal == "true") + } + + @Test + func requestEnveloped_nonSuccessCode_throwsServerErrorWithMessage() async { + let body = Data(#"{"responseCode":"LRT_001","message":"오늘 막차가 종료되었습니다","result":null}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + await #expect(throws: ServerError(code: "LRT_001", message: "오늘 막차가 종료되었습니다")) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } + + @Test + func requestEnveloped_nullResultForNonEmptyType_throws() async { + let body = Data(#"{"responseCode":"SUCCESS","result":null}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + await #expect(throws: NetworkError.self) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } + + @Test + func requestEnveloped_nullResultForEmptyType_succeeds() async throws { + let body = Data(#"{"responseCode":"SUCCESS"}"#.utf8) + let client = StubNetworkClient(result: .success(body)) + let _: APIEmptyResult = try await client.requestEnveloped(PingEndpoint()) + } + + @Test + func requestEnveloped_nonEnvelopeBodyForEmptyType_succeeds() async throws { + let client = StubNetworkClient(result: .success(Data())) + let _: APIEmptyResult = try await client.requestEnveloped(PingEndpoint()) + } + + @Test + func requestEnveloped_unacceptableStatusWithEnvelopeBody_throwsServerError() async { + let body = Data(#"{"responseCode":"URT_001","message":"등록된 경로가 없습니다","path":"/routes/user-routes/refresh"}"#.utf8) + let client = StubNetworkClient(result: .failure(.unacceptableStatus(code: 404, data: body))) + await #expect(throws: ServerError(code: "URT_001", message: "등록된 경로가 없습니다")) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } + + @Test + func requestEnveloped_unacceptableStatusWithoutEnvelopeBody_rethrowsNetworkError() async { + let client = StubNetworkClient(result: .failure(.unacceptableStatus(code: 500, data: Data()))) + await #expect(throws: NetworkError.self) { + let _: AlarmRefreshResponseDTO = try await client.requestEnveloped(PingEndpoint()) + } + } +} diff --git a/Projects/Data/Tests/LastRouteResponseDTOTests.swift b/Projects/Data/Tests/LastRouteResponseDTOTests.swift new file mode 100644 index 0000000..98e0ff0 --- /dev/null +++ b/Projects/Data/Tests/LastRouteResponseDTOTests.swift @@ -0,0 +1,90 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct LastRouteResponseDTOTests { + @Test + func toEntity_mapsLegacyShapedResponse() throws { + let json = Data(#""" + { + "routeId": "route-1", + "departureDateTime": "2026-08-22T23:40:00", + "totalTime": 2820, + "totalWalkTime": 600, + "transferCount": 1, + "totalDistance": 12000, + "totalWalkDistance": 800, + "pathType": 1, + "legs": [ + { + "distance": 300, + "sectionTime": 240, + "mode": "WALK", + "start": {"name": "강남역", "lon": 127.02761, "lat": 37.49794}, + "end": {"name": "서울역", "lon": 126.970833, "lat": 37.554722} + }, + { + "sectionTime": 1800, + "mode": "SUBWAY", + "departureDateTime": "2026-08-22T23:45:00", + "route": "수도권2호선", + "type": "2", + "subwayFinalStation": "성수", + "subwayDirection": "내선", + "isExpressSubway": false, + "isLastSubway": true + } + ] + } + """#.utf8) + + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + let route = try #require(dto.toEntity()) + + #expect(route.id == "route-1") + #expect(route.departureTime == Self.kstDate(2026, 8, 22, 23, 40)) + #expect(route.totalTime == 2820) + #expect(route.totalWalkTime == 600) + #expect(route.transferCount == 1) + #expect(route.legs.count == 2) + #expect(route.legs[0].mode == .walk) + #expect(route.legs[0].start == RoutePoint( + name: "강남역", + coordinate: Coordinate(latitude: 37.49794, longitude: 127.02761) + )) + #expect(route.legs[1].mode == .subway) + #expect(route.legs[1].departureTime == Self.kstDate(2026, 8, 22, 23, 45)) + #expect(route.legs[1].routeName == "수도권2호선") + #expect(route.legs[1].subwayFinalStation == "성수") + #expect(route.legs[1].isLastSubway) + #expect(!route.legs[1].isExpressSubway) + } + + @Test + func toEntity_unknownMode_fallsBackToUnknown() throws { + let json = Data(#"{"routeId":"r","departureDateTime":"2026-08-22T23:40:00","legs":[{"mode":"TRAM"}]}"#.utf8) + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + #expect(dto.toEntity()?.legs.first?.mode == .unknown) + } + + @Test + func toEntity_missingRouteId_returnsNil() throws { + let json = Data(#"{"departureDateTime":"2026-08-22T23:40:00"}"#.utf8) + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } + + @Test + func toEntity_unparsableDepartureDateTime_returnsNil() throws { + let json = Data(#"{"routeId":"r","departureDateTime":"not-a-date"}"#.utf8) + let dto = try JSONDecoder().decode(LastRouteResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } + + private static func kstDate(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Seoul")! + return calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute))! + } +} diff --git a/Projects/Data/Tests/PlaceEndpointTests.swift b/Projects/Data/Tests/PlaceEndpointTests.swift new file mode 100644 index 0000000..0d5a189 --- /dev/null +++ b/Projects/Data/Tests/PlaceEndpointTests.swift @@ -0,0 +1,43 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct PlaceEndpointTests { + @Test + func search_sendsKeywordAndCoordinate() { + let endpoint = PlaceEndpoint.search( + keyword: "홍대입구", + near: Coordinate(latitude: 37.556748, longitude: 126.923643) + ) + #expect(endpoint.path == "/locations") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "keyword", value: "홍대입구"), + URLQueryItem(name: "lat", value: "37.556748"), + URLQueryItem(name: "lon", value: "126.923643"), + ]) + } + + @Test + func search_withoutCoordinate_sendsZeroesLikeLegacy() { + let endpoint = PlaceEndpoint.search(keyword: "홍대입구", near: nil) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "keyword", value: "홍대입구"), + URLQueryItem(name: "lat", value: "0.0"), + URLQueryItem(name: "lon", value: "0.0"), + ]) + } + + @Test + func reverseGeocode_composesQuery() { + let endpoint = PlaceEndpoint.reverseGeocode(Coordinate(latitude: 37.560908, longitude: 126.921537)) + #expect(endpoint.path == "/locations/rgeo") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "lat", value: "37.560908"), + URLQueryItem(name: "lon", value: "126.921537"), + ]) + } +} diff --git a/Projects/Data/Tests/PlaceResponseDTOTests.swift b/Projects/Data/Tests/PlaceResponseDTOTests.swift new file mode 100644 index 0000000..e088d14 --- /dev/null +++ b/Projects/Data/Tests/PlaceResponseDTOTests.swift @@ -0,0 +1,37 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct PlaceResponseDTOTests { + @Test + func toEntity_mapsNameAddressAndCoordinate() throws { + let json = Data( + #"{"name":"홍대입구역","lat":37.556748,"lon":126.923643,"businessCategory":"지하철역","address":"서울 마포구","radius":"500"}"#.utf8 + ) + let dto = try JSONDecoder().decode(PlaceResponseDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "홍대입구역", + address: "서울 마포구", + coordinate: Coordinate(latitude: 37.556748, longitude: 126.923643) + )) + } + + @Test + func toEntity_missingOptionalMetadata_stillReturnsPlace() throws { + let json = Data(#"{"name":"홍대입구역","lat":37.556748,"lon":126.923643}"#.utf8) + let dto = try JSONDecoder().decode(PlaceResponseDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "홍대입구역", + address: "", + coordinate: Coordinate(latitude: 37.556748, longitude: 126.923643) + )) + } + + @Test + func toEntity_missingCoordinate_returnsNil() throws { + let json = Data(#"{"name":"홍대입구역"}"#.utf8) + let dto = try JSONDecoder().decode(PlaceResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } +} diff --git a/Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift b/Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift new file mode 100644 index 0000000..9448d1c --- /dev/null +++ b/Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift @@ -0,0 +1,24 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct ReverseGeocodeResponseDTOTests { + @Test + func toEntity_mapsCurrentLocationLabel() throws { + let json = Data(#"{"name":"연남동","address":"서울 마포구 연남동","lat":37.560908,"lon":126.921537}"#.utf8) + let dto = try JSONDecoder().decode(ReverseGeocodeResponseDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "연남동", + address: "서울 마포구 연남동", + coordinate: Coordinate(latitude: 37.560908, longitude: 126.921537) + )) + } + + @Test + func toEntity_missingName_returnsNil() throws { + let json = Data(#"{"address":"서울 마포구 연남동","lat":37.560908,"lon":126.921537}"#.utf8) + let dto = try JSONDecoder().decode(ReverseGeocodeResponseDTO.self, from: json) + #expect(dto.toEntity() == nil) + } +} diff --git a/Projects/Data/Tests/RouteEndpointTests.swift b/Projects/Data/Tests/RouteEndpointTests.swift new file mode 100644 index 0000000..7e8e30d --- /dev/null +++ b/Projects/Data/Tests/RouteEndpointTests.swift @@ -0,0 +1,32 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +struct RouteEndpointTests { + @Test + func search_composesPathMethodAndLegacyQueryNames() { + let endpoint = RouteEndpoint.search( + start: Coordinate(latitude: 37.49794, longitude: 127.02761), + end: Coordinate(latitude: 37.554722, longitude: 126.970833) + ) + #expect(endpoint.path == "/routes/last-routes") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems == [ + URLQueryItem(name: "startLat", value: "37.49794"), + URLQueryItem(name: "startLon", value: "127.02761"), + URLQueryItem(name: "endLat", value: "37.554722"), + URLQueryItem(name: "endLon", value: "126.970833"), + ]) + #expect(endpoint.body == nil) + } + + @Test + func detail_interpolatesRouteIdIntoPath() { + let endpoint = RouteEndpoint.detail(routeId: "route-1") + #expect(endpoint.path == "/routes/last-routes/route-1") + #expect(endpoint.method == .get) + #expect(endpoint.queryItems.isEmpty) + } +} diff --git a/Projects/Domain/Sources/Entities/AlarmInfo.swift b/Projects/Domain/Sources/Entities/AlarmInfo.swift new file mode 100644 index 0000000..41acf7c --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmInfo.swift @@ -0,0 +1,18 @@ +import Foundation + +// TODO: [미확정] 서버 계산 "알람 시각" 필드는 refresh 응답에 없다(실측: departureTime뿐) — +// 알람 시각 스펙이 확정되면 여기에 추가한다. +public struct AlarmInfo: Equatable, Sendable { + public let lastRouteId: String + /// 막차 출발 시각 (서버 재계산 값) + public let departureTime: Date? + public let updatedAt: Date? + public let isReal: Bool + + public init(lastRouteId: String, departureTime: Date?, updatedAt: Date?, isReal: Bool) { + self.lastRouteId = lastRouteId + self.departureTime = departureTime + self.updatedAt = updatedAt + self.isReal = isReal + } +} diff --git a/Projects/Domain/Sources/Entities/Coordinate.swift b/Projects/Domain/Sources/Entities/Coordinate.swift new file mode 100644 index 0000000..352728b --- /dev/null +++ b/Projects/Domain/Sources/Entities/Coordinate.swift @@ -0,0 +1,9 @@ +public struct Coordinate: Equatable, Sendable { + public let latitude: Double + public let longitude: Double + + public init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/Projects/Domain/Sources/Entities/LastRoute.swift b/Projects/Domain/Sources/Entities/LastRoute.swift new file mode 100644 index 0000000..cbefa7a --- /dev/null +++ b/Projects/Domain/Sources/Entities/LastRoute.swift @@ -0,0 +1,102 @@ +import Foundation + +public enum TransportMode: Equatable, Sendable { + case walk + case bus + case subway + case unknown +} + +public struct RoutePoint: Equatable, Sendable { + public let name: String + public let coordinate: Coordinate + + public init(name: String, coordinate: Coordinate) { + self.name = name + self.coordinate = coordinate + } +} + +public struct TransportLeg: Equatable, Sendable { + public let mode: TransportMode + /// 초 단위 + public let sectionTime: Int + /// 미터 단위 + public let distance: Int + public let departureTime: Date? + /// 버스는 "타입:번호"(예: "간선:472"), 지하철은 노선명 + public let routeName: String? + /// 노선 타입 코드 — 아이콘·색상 키 + public let lineType: String? + public let start: RoutePoint? + public let end: RoutePoint? + public let subwayFinalStation: String? + public let subwayDirection: String? + public let isExpressSubway: Bool + public let isLastSubway: Bool + + public init( + mode: TransportMode, + sectionTime: Int, + distance: Int, + departureTime: Date?, + routeName: String?, + lineType: String?, + start: RoutePoint?, + end: RoutePoint?, + subwayFinalStation: String?, + subwayDirection: String?, + isExpressSubway: Bool, + isLastSubway: Bool + ) { + self.mode = mode + self.sectionTime = sectionTime + self.distance = distance + self.departureTime = departureTime + self.routeName = routeName + self.lineType = lineType + self.start = start + self.end = end + self.subwayFinalStation = subwayFinalStation + self.subwayDirection = subwayDirection + self.isExpressSubway = isExpressSubway + self.isLastSubway = isLastSubway + } +} + +// 지도 표시 전용 필드(passShape/passStopList/step)는 2.0 스코프에 없어 이식하지 않았다. +public struct LastRoute: Equatable, Sendable { + public let id: String + /// 막차 출발 시각 + public let departureTime: Date + /// 초 단위 + public let totalTime: Int + /// 초 단위 + public let totalWalkTime: Int + public let transferCount: Int + /// 미터 단위 + public let totalDistance: Int + /// 미터 단위 + public let totalWalkDistance: Int + public let legs: [TransportLeg] + + public init( + id: String, + departureTime: Date, + totalTime: Int, + totalWalkTime: Int, + transferCount: Int, + totalDistance: Int, + totalWalkDistance: Int, + legs: [TransportLeg] + ) { + self.id = id + self.departureTime = departureTime + self.totalTime = totalTime + self.totalWalkTime = totalWalkTime + self.transferCount = transferCount + self.totalDistance = totalDistance + self.totalWalkDistance = totalWalkDistance + self.legs = legs + } +} diff --git a/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift b/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift new file mode 100644 index 0000000..e1a67df --- /dev/null +++ b/Projects/Domain/Sources/Entities/LastRouteSearchResult.swift @@ -0,0 +1,8 @@ +public enum LastRouteSearchResult: Sendable, Equatable { + /// 첫 항목 = 가장 늦은 차 + case available([LastRoute]) + /// 오늘 막차 종료 + case serviceEnded + /// 경로 없음 (도보권 등) + case noRoute +} diff --git a/Projects/Domain/Sources/Entities/Place.swift b/Projects/Domain/Sources/Entities/Place.swift new file mode 100644 index 0000000..bd6baa5 --- /dev/null +++ b/Projects/Domain/Sources/Entities/Place.swift @@ -0,0 +1,11 @@ +public struct Place: Equatable, Sendable { + public let name: String + public let address: String + public let coordinate: Coordinate + + public init(name: String, address: String, coordinate: Coordinate) { + self.name = name + self.address = address + self.coordinate = coordinate + } +} diff --git a/Projects/Domain/Sources/Entities/ServerError.swift b/Projects/Domain/Sources/Entities/ServerError.swift new file mode 100644 index 0000000..0950e40 --- /dev/null +++ b/Projects/Domain/Sources/Entities/ServerError.swift @@ -0,0 +1,10 @@ +/// 서버가 envelope의 responseCode로 알려온 비즈니스 에러. +public struct ServerError: Error, Equatable, Sendable { + public let code: String + public let message: String? + + public init(code: String, message: String? = nil) { + self.code = code + self.message = message + } +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmRepository.swift b/Projects/Domain/Sources/Interfaces/AlarmRepository.swift new file mode 100644 index 0000000..2bd099b --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmRepository.swift @@ -0,0 +1,7 @@ +// 조회 전용 GET /routes/user-routes는 서버에 없다(레거시 실측) — refresh가 조회를 겸한다. +// TODO: [미확정] 서버에 조회 API가 생기면 별도 메서드로 분리한다. +public protocol AlarmRepository: Sendable { + func register(lastRouteId: String) async throws + func cancel(lastRouteId: String) async throws + func refresh() async throws -> AlarmInfo +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift b/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift new file mode 100644 index 0000000..5e48ec1 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/AlarmScheduler.swift @@ -0,0 +1,8 @@ +import Foundation + +/// 디바이스 알람 포트 — 어댑터 구현은 Phase 7(CoreAlarm)에서 App에 둔다. +public protocol AlarmScheduler: Sendable { + /// 기존 알람을 전부 취소하고 새로 등록한다 (단일 알람 정책). + func replaceAlarm(id: String, fireDate: Date, title: String) async throws + func cancelAlarm() async +} diff --git a/Projects/Domain/Sources/Interfaces/LastRouteRepository.swift b/Projects/Domain/Sources/Interfaces/LastRouteRepository.swift new file mode 100644 index 0000000..2d1daa6 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/LastRouteRepository.swift @@ -0,0 +1,4 @@ +public protocol LastRouteRepository: Sendable { + func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] + func lastRoute(id: String) async throws -> LastRoute +} diff --git a/Projects/Domain/Sources/Interfaces/LocationService.swift b/Projects/Domain/Sources/Interfaces/LocationService.swift new file mode 100644 index 0000000..56c3c15 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/LocationService.swift @@ -0,0 +1,4 @@ +/// 디바이스 위치 포트 — 어댑터 구현은 Phase 6에서 App에 둔다. +public protocol LocationService: Sendable { + func currentLocation() async throws -> Coordinate +} diff --git a/Projects/Domain/Sources/Interfaces/PlaceRepository.swift b/Projects/Domain/Sources/Interfaces/PlaceRepository.swift new file mode 100644 index 0000000..45989a6 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/PlaceRepository.swift @@ -0,0 +1,4 @@ +public protocol PlaceRepository: Sendable { + func searchPlaces(keyword: String, near coordinate: Coordinate?) async throws -> [Place] + func reverseGeocode(_ coordinate: Coordinate) async throws -> Place +} diff --git a/Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift b/Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift new file mode 100644 index 0000000..ef54da6 --- /dev/null +++ b/Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift @@ -0,0 +1,6 @@ +/// 로컬 저장 전용 — 구현은 Phase 2(CoreStorage)에서. +public protocol RecentSearchRepository: Sendable { + func recentSearches() async throws -> [Place] + func save(_ place: Place) async throws + func remove(_ place: Place) async throws +} diff --git a/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift new file mode 100644 index 0000000..aec245e --- /dev/null +++ b/Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift @@ -0,0 +1,18 @@ +public protocol CancelAlarmUseCase: Sendable { + func execute(lastRouteId: String) async throws +} + +public struct DefaultCancelAlarmUseCase: CancelAlarmUseCase { + private let repository: any AlarmRepository + private let scheduler: any AlarmScheduler + + public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + self.repository = repository + self.scheduler = scheduler + } + + public func execute(lastRouteId: String) async throws { + try await repository.cancel(lastRouteId: lastRouteId) + await scheduler.cancelAlarm() + } +} diff --git a/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift b/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift new file mode 100644 index 0000000..80cac68 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift @@ -0,0 +1,15 @@ +public protocol GetCurrentLocationUseCase: Sendable { + func execute() async throws -> Coordinate +} + +public struct DefaultGetCurrentLocationUseCase: GetCurrentLocationUseCase { + private let locationService: any LocationService + + public init(locationService: any LocationService) { + self.locationService = locationService + } + + public func execute() async throws -> Coordinate { + try await locationService.currentLocation() + } +} diff --git a/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift b/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift new file mode 100644 index 0000000..59497f3 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift @@ -0,0 +1,25 @@ +public protocol RecentSearchesUseCase: Sendable { + func fetch() async throws -> [Place] + func save(_ place: Place) async throws + func remove(_ place: Place) async throws +} + +public struct DefaultRecentSearchesUseCase: RecentSearchesUseCase { + private let repository: any RecentSearchRepository + + public init(repository: any RecentSearchRepository) { + self.repository = repository + } + + public func fetch() async throws -> [Place] { + try await repository.recentSearches() + } + + public func save(_ place: Place) async throws { + try await repository.save(place) + } + + public func remove(_ place: Place) async throws { + try await repository.remove(place) + } +} diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift new file mode 100644 index 0000000..73afeed --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -0,0 +1,15 @@ +public protocol RefreshAlarmUseCase: Sendable { + func execute() async throws -> AlarmInfo +} + +public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { + private let repository: any AlarmRepository + + public init(repository: any AlarmRepository) { + self.repository = repository + } + + public func execute() async throws -> AlarmInfo { + try await repository.refresh() + } +} diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift new file mode 100644 index 0000000..a0c136a --- /dev/null +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -0,0 +1,25 @@ +public protocol RegisterAlarmUseCase: Sendable { + func execute(route: LastRoute) async throws +} + +public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { + private let repository: any AlarmRepository + private let scheduler: any AlarmScheduler + + public init(repository: any AlarmRepository, scheduler: any AlarmScheduler) { + self.repository = repository + self.scheduler = scheduler + } + + public func execute(route: LastRoute) async throws { + // TODO: [미확정 #4] 단일 알람 규약(서버 교체 여부) 확정 전까지 클라이언트가 삭제 후 등록한다. + // 기존 알람 확인 실패(= 등록된 알람 없음)와 삭제 실패는 등록을 막지 않는다. + if let existing = try? await repository.refresh() { + try? await repository.cancel(lastRouteId: existing.lastRouteId) + } + try await repository.register(lastRouteId: route.id) + // 단일 알람 정책: 서버 등록이 성공한 뒤에만 로컬 알람을 교체한다. + // TODO: [미확정] 서버 계산 알람 시각 스펙 확정 전까지 막차 출발 시각으로 스케줄한다. + try await scheduler.replaceAlarm(id: route.id, fireDate: route.departureTime, title: "막차 출발 알림") + } +} diff --git a/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift new file mode 100644 index 0000000..f872c35 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift @@ -0,0 +1,34 @@ +public protocol SearchLastRoutesUseCase: Sendable { + func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult +} + +public struct DefaultSearchLastRoutesUseCase: SearchLastRoutesUseCase { + private let repository: any LastRouteRepository + + public init(repository: any LastRouteRepository) { + self.repository = repository + } + + public func execute(start: Coordinate, end: Coordinate) async throws -> LastRouteSearchResult { + let routes: [LastRoute] + do { + routes = try await repository.searchLastRoutes(start: start, end: end) + } catch let error as ServerError { + if let normalized = Self.normalizedResult(code: error.code) { + return normalized + } + throw error + } + // TODO: [미확정 #3] 서버의 "막차 종료" 표현 실측 전까지 빈 목록을 종료로 간주한다. + guard !routes.isEmpty else { return .serviceEnded } + return .available(routes) + } + + // TODO: [미확정 #3] "막차 종료"/"경로 없음"의 responseCode 실측값이 확정되면 이 매핑에만 추가한다. + // 레거시 단서(의미 미확인): URT_001, LRT_001, LRT_003, REQ_004 + private static func normalizedResult(code: String) -> LastRouteSearchResult? { + switch code { + default: nil + } + } +} diff --git a/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift b/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift new file mode 100644 index 0000000..bef8819 --- /dev/null +++ b/Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift @@ -0,0 +1,15 @@ +public protocol SearchPlacesUseCase: Sendable { + func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] +} + +public struct DefaultSearchPlacesUseCase: SearchPlacesUseCase { + private let repository: any PlaceRepository + + public init(repository: any PlaceRepository) { + self.repository = repository + } + + public func execute(keyword: String, near coordinate: Coordinate?) async throws -> [Place] { + try await repository.searchPlaces(keyword: keyword, near: coordinate) + } +} diff --git a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift new file mode 100644 index 0000000..12eb8d0 --- /dev/null +++ b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift @@ -0,0 +1,67 @@ +@testable import Domain +import Foundation +import Testing + +private actor CallLog { + private(set) var events: [String] = [] + func append(_ event: String) { events.append(event) } +} + +private struct StubError: Error {} + +private struct SpyAlarmRepository: AlarmRepository { + let log: CallLog + var cancelError: Error? = nil + + func register(lastRouteId: String) async throws { + await log.append("register:\(lastRouteId)") + } + + func cancel(lastRouteId: String) async throws { + await log.append("cancel:\(lastRouteId)") + if let cancelError { throw cancelError } + } + + func refresh() async throws -> AlarmInfo { + await log.append("refresh") + throw StubError() + } +} + +private struct SpyAlarmScheduler: AlarmScheduler { + let log: CallLog + + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { + await log.append("replaceAlarm:\(id)") + } + + func cancelAlarm() async { + await log.append("cancelAlarm") + } +} + +struct DefaultCancelAlarmUseCaseTests { + @Test + func execute_serverCancelSucceeds_thenCancelsLocalAlarm() async throws { + let log = CallLog() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log) + ) + try await sut.execute(lastRouteId: "route-1") + #expect(await log.events == ["cancel:route-1", "cancelAlarm"]) + } + + @Test + func execute_serverCancelFails_keepsLocalAlarm() async { + let log = CallLog() + let sut = DefaultCancelAlarmUseCase( + repository: SpyAlarmRepository(log: log, cancelError: StubError()), + scheduler: SpyAlarmScheduler(log: log) + ) + await #expect(throws: StubError.self) { + try await sut.execute(lastRouteId: "route-1") + } + #expect(await log.events == ["cancel:route-1"]) + } +} diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift new file mode 100644 index 0000000..c71078f --- /dev/null +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -0,0 +1,96 @@ +@testable import Domain +import Foundation +import Testing + +private actor CallLog { + private(set) var events: [String] = [] + func append(_ event: String) { events.append(event) } +} + +private struct StubError: Error {} + +private struct SpyAlarmRepository: AlarmRepository { + let log: CallLog + var existing: AlarmInfo? = nil + var registerError: Error? = nil + + func register(lastRouteId: String) async throws { + await log.append("register:\(lastRouteId)") + if let registerError { throw registerError } + } + + func cancel(lastRouteId: String) async throws { + await log.append("cancel:\(lastRouteId)") + } + + func refresh() async throws -> AlarmInfo { + await log.append("refresh") + guard let existing else { throw StubError() } + return existing + } +} + +private struct SpyAlarmScheduler: AlarmScheduler { + let log: CallLog + + func replaceAlarm(id: String, fireDate: Date, title: String) async throws { + await log.append("replaceAlarm:\(id)") + } + + func cancelAlarm() async { + await log.append("cancelAlarm") + } +} + +private extension LastRoute { + static func fixture(id: String) -> LastRoute { + LastRoute( + id: id, + departureTime: Date(timeIntervalSince1970: 1_000), + totalTime: 0, + totalWalkTime: 0, + transferCount: 0, + totalDistance: 0, + totalWalkDistance: 0, + legs: [] + ) + } +} + +struct DefaultRegisterAlarmUseCaseTests { + @Test + func execute_existingAlarm_cancelsThenRegistersThenSchedules() async throws { + let log = CallLog() + let existing = AlarmInfo(lastRouteId: "old", departureTime: nil, updatedAt: nil, isReal: false) + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log, existing: existing), + scheduler: SpyAlarmScheduler(log: log) + ) + try await sut.execute(route: .fixture(id: "new")) + #expect(await log.events == ["refresh", "cancel:old", "register:new", "replaceAlarm:new"]) + } + + @Test + func execute_noExistingAlarm_skipsCancel() async throws { + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log), + scheduler: SpyAlarmScheduler(log: log) + ) + try await sut.execute(route: .fixture(id: "new")) + #expect(await log.events == ["refresh", "register:new", "replaceAlarm:new"]) + } + + @Test + func execute_serverRegisterFails_doesNotSchedule() async { + let log = CallLog() + let sut = DefaultRegisterAlarmUseCase( + repository: SpyAlarmRepository(log: log, registerError: StubError()), + scheduler: SpyAlarmScheduler(log: log) + ) + await #expect(throws: StubError.self) { + try await sut.execute(route: .fixture(id: "new")) + } + #expect(await log.events == ["refresh", "register:new"]) + } +} diff --git a/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift b/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift new file mode 100644 index 0000000..1768fe5 --- /dev/null +++ b/Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift @@ -0,0 +1,66 @@ +@testable import Domain +import Foundation +import Testing + +private struct StubError: Error {} + +private struct StubLastRouteRepository: LastRouteRepository { + let routes: [LastRoute] + var error: Error? = nil + + func searchLastRoutes(start: Coordinate, end: Coordinate) async throws -> [LastRoute] { + if let error { throw error } + return routes + } + + func lastRoute(id: String) async throws -> LastRoute { + if let error { throw error } + guard let route = routes.first else { throw StubError() } + return route + } +} + +private extension LastRoute { + static func fixture(id: String) -> LastRoute { + LastRoute( + id: id, + departureTime: Date(timeIntervalSince1970: 1_000), + totalTime: 0, + totalWalkTime: 0, + transferCount: 0, + totalDistance: 0, + totalWalkDistance: 0, + legs: [] + ) + } +} + +struct DefaultSearchLastRoutesUseCaseTests { + private let start = Coordinate(latitude: 37.49794, longitude: 127.02761) + private let end = Coordinate(latitude: 37.554722, longitude: 126.970833) + + @Test + func execute_nonEmptyRoutes_returnsAvailablePreservingOrder() async throws { + let routes = [LastRoute.fixture(id: "latest"), LastRoute.fixture(id: "alternative")] + let sut = DefaultSearchLastRoutesUseCase(repository: StubLastRouteRepository(routes: routes)) + let result = try await sut.execute(start: start, end: end) + #expect(result == .available(routes)) + } + + @Test + func execute_emptyRoutes_returnsServiceEnded() async throws { + let sut = DefaultSearchLastRoutesUseCase(repository: StubLastRouteRepository(routes: [])) + let result = try await sut.execute(start: start, end: end) + #expect(result == .serviceEnded) + } + + @Test + func execute_unknownServerError_rethrows() async { + let sut = DefaultSearchLastRoutesUseCase( + repository: StubLastRouteRepository(routes: [], error: ServerError(code: "LRT_999")) + ) + await #expect(throws: ServerError(code: "LRT_999")) { + try await sut.execute(start: start, end: end) + } + } +}