From f5c19df1fdbf7852ec31ce88d20791128ce31de1 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 13:11:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20AtchaV2=20Phase=201=20=E2=80=94=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B3=84=EC=95=BD=20=EC=9D=B4=EC=8B=9D=20?= =?UTF-8?q?(Domain=20=ED=99=95=EC=9E=A5=20+=20AtchaData=20=EC=8B=A4=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Domain: Place/LastRoute/AlarmInfo 엔티티, LastRouteSearchResult 정규화 enum, Repository·디바이스 포트 6종, UseCase 7종 (등록 순서·정규화 정책 포함) - AtchaData: Route/Place/Alarm 엔드포인트, APIResponse envelope 해체 헬퍼, 레거시 실측 DTO 이식(toEntity, KST 고정 파싱, mode unknown 폴백), RepositoryImpl 3종 - 테스트: Domain 정책 8개 + AtchaData 인라인 픽스처 28개 (Swift Testing) - 미확정 입력 #3(빈 목록=serviceEnded 가정)·#4(클라 삭제 후 등록)는 TODO로 격리 Co-Authored-By: Claude Fable 5 --- .../Sources/DTO/AlarmRefreshResponseDTO.swift | 20 ++++ .../Sources/DTO/AlarmRegisterRequestDTO.swift | 7 ++ .../Sources/DTO/LastRouteResponseDTO.swift | 87 +++++++++++++++ .../Data/Sources/DTO/PlaceResponseDTO.swift | 16 +++ .../DTO/ReverseGeocodeResponseDTO.swift | 13 +++ .../Data/Sources/Network/APIResponse.swift | 17 +++ .../Data/Sources/Network/AlarmEndpoint.swift | 46 ++++++++ .../Network/NetworkClient+Envelope.swift | 46 ++++++++ .../Data/Sources/Network/PlaceEndpoint.swift | 38 +++++++ .../Data/Sources/Network/RouteEndpoint.swift | 34 ++++++ .../Sources/Network/ServerDateParser.swift | 18 ++++ .../Repositories/AlarmRepositoryImpl.swift | 30 ++++++ .../LastRouteRepositoryImpl.swift | 27 +++++ .../Repositories/PlaceRepositoryImpl.swift | 27 +++++ Projects/Data/Tests/AlarmEndpointTests.swift | 36 +++++++ .../Tests/AlarmRefreshResponseDTOTests.swift | 33 ++++++ Projects/Data/Tests/EnvelopeTests.swift | 84 +++++++++++++++ .../Tests/LastRouteResponseDTOTests.swift | 90 ++++++++++++++++ Projects/Data/Tests/PlaceEndpointTests.swift | 43 ++++++++ .../Data/Tests/PlaceResponseDTOTests.swift | 37 +++++++ .../ReverseGeocodeResponseDTOTests.swift | 24 +++++ Projects/Data/Tests/RouteEndpointTests.swift | 32 ++++++ .../Domain/Sources/Entities/AlarmInfo.swift | 18 ++++ .../Domain/Sources/Entities/Coordinate.swift | 9 ++ .../Domain/Sources/Entities/LastRoute.swift | 102 ++++++++++++++++++ .../Entities/LastRouteSearchResult.swift | 8 ++ Projects/Domain/Sources/Entities/Place.swift | 11 ++ .../Domain/Sources/Entities/ServerError.swift | 10 ++ .../Sources/Interfaces/AlarmRepository.swift | 7 ++ .../Sources/Interfaces/AlarmScheduler.swift | 8 ++ .../Interfaces/LastRouteRepository.swift | 4 + .../Sources/Interfaces/LocationService.swift | 4 + .../Sources/Interfaces/PlaceRepository.swift | 4 + .../Interfaces/RecentSearchRepository.swift | 6 ++ .../Sources/UseCases/CancelAlarmUseCase.swift | 18 ++++ .../UseCases/GetCurrentLocationUseCase.swift | 15 +++ .../UseCases/RecentSearchesUseCase.swift | 25 +++++ .../UseCases/RefreshAlarmUseCase.swift | 15 +++ .../UseCases/RegisterAlarmUseCase.swift | 25 +++++ .../UseCases/SearchLastRoutesUseCase.swift | 34 ++++++ .../UseCases/SearchPlacesUseCase.swift | 15 +++ .../DefaultCancelAlarmUseCaseTests.swift | 67 ++++++++++++ .../DefaultRegisterAlarmUseCaseTests.swift | 96 +++++++++++++++++ .../DefaultSearchLastRoutesUseCaseTests.swift | 66 ++++++++++++ 44 files changed, 1372 insertions(+) create mode 100644 Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift create mode 100644 Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift create mode 100644 Projects/Data/Sources/DTO/LastRouteResponseDTO.swift create mode 100644 Projects/Data/Sources/DTO/PlaceResponseDTO.swift create mode 100644 Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift create mode 100644 Projects/Data/Sources/Network/APIResponse.swift create mode 100644 Projects/Data/Sources/Network/AlarmEndpoint.swift create mode 100644 Projects/Data/Sources/Network/NetworkClient+Envelope.swift create mode 100644 Projects/Data/Sources/Network/PlaceEndpoint.swift create mode 100644 Projects/Data/Sources/Network/RouteEndpoint.swift create mode 100644 Projects/Data/Sources/Network/ServerDateParser.swift create mode 100644 Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift create mode 100644 Projects/Data/Sources/Repositories/LastRouteRepositoryImpl.swift create mode 100644 Projects/Data/Sources/Repositories/PlaceRepositoryImpl.swift create mode 100644 Projects/Data/Tests/AlarmEndpointTests.swift create mode 100644 Projects/Data/Tests/AlarmRefreshResponseDTOTests.swift create mode 100644 Projects/Data/Tests/EnvelopeTests.swift create mode 100644 Projects/Data/Tests/LastRouteResponseDTOTests.swift create mode 100644 Projects/Data/Tests/PlaceEndpointTests.swift create mode 100644 Projects/Data/Tests/PlaceResponseDTOTests.swift create mode 100644 Projects/Data/Tests/ReverseGeocodeResponseDTOTests.swift create mode 100644 Projects/Data/Tests/RouteEndpointTests.swift create mode 100644 Projects/Domain/Sources/Entities/AlarmInfo.swift create mode 100644 Projects/Domain/Sources/Entities/Coordinate.swift create mode 100644 Projects/Domain/Sources/Entities/LastRoute.swift create mode 100644 Projects/Domain/Sources/Entities/LastRouteSearchResult.swift create mode 100644 Projects/Domain/Sources/Entities/Place.swift create mode 100644 Projects/Domain/Sources/Entities/ServerError.swift create mode 100644 Projects/Domain/Sources/Interfaces/AlarmRepository.swift create mode 100644 Projects/Domain/Sources/Interfaces/AlarmScheduler.swift create mode 100644 Projects/Domain/Sources/Interfaces/LastRouteRepository.swift create mode 100644 Projects/Domain/Sources/Interfaces/LocationService.swift create mode 100644 Projects/Domain/Sources/Interfaces/PlaceRepository.swift create mode 100644 Projects/Domain/Sources/Interfaces/RecentSearchRepository.swift create mode 100644 Projects/Domain/Sources/UseCases/CancelAlarmUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/GetCurrentLocationUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/RecentSearchesUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/SearchLastRoutesUseCase.swift create mode 100644 Projects/Domain/Sources/UseCases/SearchPlacesUseCase.swift create mode 100644 Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift create mode 100644 Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift create mode 100644 Projects/Domain/Tests/DefaultSearchLastRoutesUseCaseTests.swift 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) + } + } +} From 57156c43003c47f6b75349856f2c35d652e8f75e Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 13:39:28 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20AtchaV2=20Phase=202=20=E2=80=94=20C?= =?UTF-8?q?oreStorage=20=EB=AA=A8=EB=93=88=20+=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=A1=9C=EC=BB=AC=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoreStorage 레이어 모듈 신설 (KeyValueStore 프로토콜 + Codable 헬퍼, UserDefaultsKeyValueStore, KeychainStore — 레거시 키체인+메모리 캐시 패턴을 Mutex 기반 Sendable-clean으로 재작성) - AtchaData에 RecentSearchRepositoryImpl (최대 10개, 중복 시 최신 갱신, 최신순, 삭제 지원) + RecentSearchRecordDTO - 테스트는 인메모리 KeyValueStore 스텁 (실 UserDefaults 미사용) Co-Authored-By: Claude Fable 5 --- Projects/Core/Storage/Project.swift | 4 + .../Sources/KeyValueStore+Codable.swift | 14 +++ .../Core/Storage/Sources/KeyValueStore.swift | 8 ++ .../Core/Storage/Sources/KeychainStore.swift | 76 +++++++++++++ .../Sources/UserDefaultsKeyValueStore.swift | 22 ++++ .../Tests/KeyValueStoreCodableTests.swift | 63 +++++++++++ Projects/Data/Project.swift | 1 + .../Sources/DTO/RecentSearchRecordDTO.swift | 20 ++++ .../RecentSearchRepositoryImpl.swift | 35 ++++++ .../Tests/RecentSearchRecordDTOTests.swift | 31 ++++++ .../RecentSearchRepositoryImplTests.swift | 104 ++++++++++++++++++ Workspace.swift | 1 + 12 files changed, 379 insertions(+) create mode 100644 Projects/Core/Storage/Project.swift create mode 100644 Projects/Core/Storage/Sources/KeyValueStore+Codable.swift create mode 100644 Projects/Core/Storage/Sources/KeyValueStore.swift create mode 100644 Projects/Core/Storage/Sources/KeychainStore.swift create mode 100644 Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift create mode 100644 Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift create mode 100644 Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift create mode 100644 Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift create mode 100644 Projects/Data/Tests/RecentSearchRecordDTOTests.swift create mode 100644 Projects/Data/Tests/RecentSearchRepositoryImplTests.swift diff --git a/Projects/Core/Storage/Project.swift b/Projects/Core/Storage/Project.swift new file mode 100644 index 0000000..fb939fb --- /dev/null +++ b/Projects/Core/Storage/Project.swift @@ -0,0 +1,4 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer(name: "CoreStorage", bundleSuffix: "core.storage", isolation: .nonisolated) diff --git a/Projects/Core/Storage/Sources/KeyValueStore+Codable.swift b/Projects/Core/Storage/Sources/KeyValueStore+Codable.swift new file mode 100644 index 0000000..9247481 --- /dev/null +++ b/Projects/Core/Storage/Sources/KeyValueStore+Codable.swift @@ -0,0 +1,14 @@ +import Foundation + +public extension KeyValueStore { + /// 부재 → nil. 저장된 데이터가 디코딩 불가면 throw (숨기지 않는다 — 무시 정책은 호출자 몫). + // JSONDecoder/JSONEncoder는 non-Sendable — 공유하지 않고 호출마다 새로 만든다. + func value(_ type: T.Type = T.self, forKey key: String) throws -> T? { + guard let data = try data(forKey: key) else { return nil } + return try JSONDecoder().decode(T.self, from: data) + } + + func setValue(_ value: T, forKey key: String) throws { + try set(JSONEncoder().encode(value), forKey: key) + } +} diff --git a/Projects/Core/Storage/Sources/KeyValueStore.swift b/Projects/Core/Storage/Sources/KeyValueStore.swift new file mode 100644 index 0000000..dbf9912 --- /dev/null +++ b/Projects/Core/Storage/Sources/KeyValueStore.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Data 단위 key-value 저장 추상화. `nil` = 값 부재, `throw` = 실제 저장소 실패. +public protocol KeyValueStore: Sendable { + func data(forKey key: String) throws -> Data? + func set(_ data: Data, forKey key: String) throws + func removeValue(forKey key: String) throws +} diff --git a/Projects/Core/Storage/Sources/KeychainStore.swift b/Projects/Core/Storage/Sources/KeychainStore.swift new file mode 100644 index 0000000..e5a251b --- /dev/null +++ b/Projects/Core/Storage/Sources/KeychainStore.swift @@ -0,0 +1,76 @@ +import Foundation +import Security +import Synchronization + +public enum KeychainError: Error, Equatable, Sendable { + case unexpectedStatus(OSStatus) +} + +/// 레거시 TokenStorage의 키체인+메모리 캐시 의미를 프로토콜 기반으로 재작성. +/// 캐시는 존재값만 보관하며 Mutex로 동기화한다 (레거시는 unsynchronized라 Swift 6 불가). +public final class KeychainStore: KeyValueStore { + private let service: String + private let cache = Mutex<[String: Data]>([:]) + + public init(service: String = "com.atcha.iOS.v2") { + self.service = service + } + + public func data(forKey key: String) throws -> Data? { + if let cached = cache.withLock({ $0[key] }) { + return cached + } + var query = baseQuery(forKey: key) + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let data = result as? Data else { return nil } + cache.withLock { $0[key] = data } + return data + case errSecItemNotFound: + return nil + default: + throw KeychainError.unexpectedStatus(status) + } + } + + public func set(_ data: Data, forKey key: String) throws { + var addQuery = baseQuery(forKey: key) + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + + // 레거시의 delete-then-add는 비원자적 — add 후 duplicate면 update로 대체한다. + var status = SecItemAdd(addQuery as CFDictionary, nil) + if status == errSecDuplicateItem { + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + status = SecItemUpdate(baseQuery(forKey: key) as CFDictionary, attributes as CFDictionary) + } + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + cache.withLock { $0[key] = data } + } + + public func removeValue(forKey key: String) throws { + let status = SecItemDelete(baseQuery(forKey: key) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + cache.withLock { $0[key] = nil } + } + + private func baseQuery(forKey key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} diff --git a/Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift b/Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift new file mode 100644 index 0000000..2b2dd77 --- /dev/null +++ b/Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift @@ -0,0 +1,22 @@ +import Foundation + +// UserDefaults는 문서화된 thread-safe지만 SDK가 Sendable로 표기하지 않아 @unchecked가 필요하다. +public final class UserDefaultsKeyValueStore: KeyValueStore, @unchecked Sendable { + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func data(forKey key: String) throws -> Data? { + defaults.data(forKey: key) + } + + public func set(_ data: Data, forKey key: String) throws { + defaults.set(data, forKey: key) + } + + public func removeValue(forKey key: String) throws { + defaults.removeObject(forKey: key) + } +} diff --git a/Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift b/Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift new file mode 100644 index 0000000..6f76c61 --- /dev/null +++ b/Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift @@ -0,0 +1,63 @@ +@testable import CoreStorage +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { + storage.withLock { $0[key] } + } + + func set(_ data: Data, forKey key: String) throws { + storage.withLock { $0[key] = data } + } + + func removeValue(forKey key: String) throws { + storage.withLock { $0[key] = nil } + } +} + +private struct Token: Codable, Equatable { + let value: String + let expiresAt: Int +} + +struct KeyValueStoreCodableTests { + private let store = InMemoryKeyValueStore() + + @Test + func setValue_thenValue_roundTripsCodable() throws { + let token = Token(value: "abc", expiresAt: 123) + try store.setValue(token, forKey: "token") + #expect(try store.value(Token.self, forKey: "token") == token) + } + + @Test + func value_missingKey_returnsNil() throws { + #expect(try store.value(Token.self, forKey: "missing") == nil) + } + + @Test + func value_corruptData_throwsDecodingError() throws { + try store.set(Data("not json".utf8), forKey: "token") + #expect(throws: DecodingError.self) { + try store.value(Token.self, forKey: "token") + } + } + + @Test + func setValue_overwrite_replacesPreviousValue() throws { + try store.setValue(Token(value: "old", expiresAt: 1), forKey: "token") + try store.setValue(Token(value: "new", expiresAt: 2), forKey: "token") + #expect(try store.value(Token.self, forKey: "token") == Token(value: "new", expiresAt: 2)) + } + + @Test + func removeValue_thenValue_returnsNil() throws { + try store.setValue(Token(value: "abc", expiresAt: 1), forKey: "token") + try store.removeValue(forKey: "token") + #expect(try store.value(Token.self, forKey: "token") == nil) + } +} diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift index c1f0483..b0eb24e 100644 --- a/Projects/Data/Project.swift +++ b/Projects/Data/Project.swift @@ -9,5 +9,6 @@ let project = Project.layer( dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "CoreNetwork", path: "../Core/Network"), + .project(target: "CoreStorage", path: "../Core/Storage"), ] ) diff --git a/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift b/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift new file mode 100644 index 0000000..bc6b231 --- /dev/null +++ b/Projects/Data/Sources/DTO/RecentSearchRecordDTO.swift @@ -0,0 +1,20 @@ +import Domain + +/// 최근 검색 로컬 저장용 레코드 — Domain 엔티티는 Codable을 채택하지 않으므로 여기서 변환한다. +public struct RecentSearchRecordDTO: Codable, Equatable, Sendable { + public let name: String + public let address: String + public let latitude: Double + public let longitude: Double + + public init(_ place: Place) { + name = place.name + address = place.address + latitude = place.coordinate.latitude + longitude = place.coordinate.longitude + } + + public func toEntity() -> Place { + Place(name: name, address: address, coordinate: Coordinate(latitude: latitude, longitude: longitude)) + } +} diff --git a/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift new file mode 100644 index 0000000..02c8379 --- /dev/null +++ b/Projects/Data/Sources/Repositories/RecentSearchRepositoryImpl.swift @@ -0,0 +1,35 @@ +import CoreStorage +import Domain + +public struct RecentSearchRepositoryImpl: RecentSearchRepository { + private let store: any KeyValueStore + private let maxCount: Int + private let storageKey = "recentSearches" + + public init(store: any KeyValueStore, maxCount: Int = 10) { + self.store = store + self.maxCount = maxCount + } + + public func recentSearches() async throws -> [Place] { + loadRecords().map { $0.toEntity() } + } + + public func save(_ place: Place) async throws { + var records = loadRecords() + records.removeAll { $0.toEntity() == place } + records.insert(RecentSearchRecordDTO(place), at: 0) + try store.setValue(Array(records.prefix(maxCount)), forKey: storageKey) + } + + public func remove(_ place: Place) async throws { + var records = loadRecords() + records.removeAll { $0.toEntity() == place } + try store.setValue(records, forKey: storageKey) + } + + /// 부재·손상 데이터는 빈 목록으로 — 일회성 캐시라 다음 save가 덮어써 자가 치유한다. + private func loadRecords() -> [RecentSearchRecordDTO] { + (try? store.value([RecentSearchRecordDTO].self, forKey: storageKey)) ?? [] + } +} diff --git a/Projects/Data/Tests/RecentSearchRecordDTOTests.swift b/Projects/Data/Tests/RecentSearchRecordDTOTests.swift new file mode 100644 index 0000000..f6d8e92 --- /dev/null +++ b/Projects/Data/Tests/RecentSearchRecordDTOTests.swift @@ -0,0 +1,31 @@ +@testable import AtchaData +import Domain +import Foundation +import Testing + +struct RecentSearchRecordDTOTests { + @Test + func decode_inlineJSON_mapsFields() throws { + let json = Data( + #"{"name":"홍대입구역","address":"서울 마포구","latitude":37.556748,"longitude":126.923643}"#.utf8 + ) + let dto = try JSONDecoder().decode(RecentSearchRecordDTO.self, from: json) + #expect(dto.toEntity() == Place( + name: "홍대입구역", + address: "서울 마포구", + coordinate: Coordinate(latitude: 37.556748, longitude: 126.923643) + )) + } + + @Test + func roundTrip_placeToRecordToEntity_preservesFields() throws { + let place = Place( + name: "서울역", + address: "서울 용산구", + coordinate: Coordinate(latitude: 37.554722, longitude: 126.970833) + ) + let encoded = try JSONEncoder().encode(RecentSearchRecordDTO(place)) + let decoded = try JSONDecoder().decode(RecentSearchRecordDTO.self, from: encoded) + #expect(decoded.toEntity() == place) + } +} diff --git a/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift b/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift new file mode 100644 index 0000000..cd89dd5 --- /dev/null +++ b/Projects/Data/Tests/RecentSearchRepositoryImplTests.swift @@ -0,0 +1,104 @@ +@testable import AtchaData +import CoreStorage +import Domain +import Foundation +import Synchronization +import Testing + +private final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { + storage.withLock { $0[key] } + } + + func set(_ data: Data, forKey key: String) throws { + storage.withLock { $0[key] = data } + } + + func removeValue(forKey key: String) throws { + storage.withLock { $0[key] = nil } + } +} + +private extension Place { + static func fixture( + name: String = "홍대입구역", + address: String = "서울 마포구", + latitude: Double = 37.556748, + longitude: Double = 126.923643 + ) -> Place { + Place(name: name, address: address, coordinate: Coordinate(latitude: latitude, longitude: longitude)) + } +} + +struct RecentSearchRepositoryImplTests { + private let store = InMemoryKeyValueStore() + private var sut: RecentSearchRepositoryImpl { RecentSearchRepositoryImpl(store: store) } + + @Test + func recentSearches_emptyStore_returnsEmpty() async throws { + #expect(try await sut.recentSearches() == []) + } + + @Test + func save_thenFetch_returnsPlace() async throws { + try await sut.save(.fixture()) + #expect(try await sut.recentSearches() == [.fixture()]) + } + + @Test + func save_ordersNewestFirst() async throws { + try await sut.save(.fixture(name: "첫번째")) + try await sut.save(.fixture(name: "두번째")) + try await sut.save(.fixture(name: "세번째")) + #expect(try await sut.recentSearches().map(\.name) == ["세번째", "두번째", "첫번째"]) + } + + @Test + func save_duplicate_movesToFrontWithoutDuplicate() async throws { + try await sut.save(.fixture(name: "홍대입구역")) + try await sut.save(.fixture(name: "서울역")) + try await sut.save(.fixture(name: "홍대입구역")) + #expect(try await sut.recentSearches().map(\.name) == ["홍대입구역", "서울역"]) + } + + @Test + func save_eleventhItem_dropsOldest_capsAtTen() async throws { + for index in 1 ... 11 { + try await sut.save(.fixture(name: "역\(index)")) + } + let names = try await sut.recentSearches().map(\.name) + #expect(names.count == 10) + #expect(names.first == "역11") + #expect(!names.contains("역1")) + } + + @Test + func remove_deletesOnlyMatchingPlace() async throws { + try await sut.save(.fixture(name: "홍대입구역")) + try await sut.save(.fixture(name: "서울역")) + try await sut.remove(.fixture(name: "홍대입구역")) + #expect(try await sut.recentSearches().map(\.name) == ["서울역"]) + } + + @Test + func remove_absentPlace_isNoop() async throws { + try await sut.save(.fixture(name: "서울역")) + try await sut.remove(.fixture(name: "없는역")) + #expect(try await sut.recentSearches().map(\.name) == ["서울역"]) + } + + @Test + func recentSearches_corruptStoredData_returnsEmpty() async throws { + try store.set(Data("not json".utf8), forKey: "recentSearches") + #expect(try await sut.recentSearches() == []) + } + + @Test + func save_persistsAcrossRepositoryInstances() async throws { + try await RecentSearchRepositoryImpl(store: store).save(.fixture()) + let other = RecentSearchRepositoryImpl(store: store) + #expect(try await other.recentSearches() == [.fixture()]) + } +} diff --git a/Workspace.swift b/Workspace.swift index b65b0e5..90ec6c2 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -8,6 +8,7 @@ let workspace = Workspace( "Projects/Domain", "Projects/Data", "Projects/Core/Network", + "Projects/Core/Storage", "Projects/Core/Coordinator", "Projects/DesignSystem", "Projects/Legacy", From 288fb6a80882f0d26039f7ce9d533f74f3b5cfd5 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 13:49:26 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20CLAUDE.md=20=EB=B0=8F=20AtchaV2=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EA=B5=AC=ED=98=84=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 79 +++++ docs/prompts/atcha-v2-master-prompt.md | 389 +++++++++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/prompts/atcha-v2-master-prompt.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8c168ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 프로젝트 개요 + +**앗차(Atcha)** — 위치 기반으로 막차(버스/지하철) 시간을 확인하고 출발 알림을 주는 iOS 앱. 이 레포에는 두 세계가 공존한다: + +- **Tuist 워크스페이스** (`Atcha.xcworkspace`, 생성물) — "메인 2.0" **AtchaV2** 개발이 이뤄지는 곳. 모든 신규 작업은 여기서. +- **레거시** (`Atcha-iOS.xcodeproj` + `Atcha-iOS/` 소스) — 기존 1.x 앱. Tuist에서 `Projects/Legacy`의 단일 타겟으로도 래핑돼 있지만, **CI/fastlane은 아직 기존 xcodeproj를 직접 빌드**하므로 기존 xcodeproj를 삭제·수정하지 말 것. 참고용. + +## 필수 명령어 + +```bash +# 최초 1회 (클론 직후·xcconfig 없을 때): 스탠드인 xcconfig 생성 + tuist install + generate +sh Scripts/bootstrap.sh + +# 매니페스트(Project.swift 등) 수정 후 재생성 +tuist generate --no-open + +# AtchaV2 빌드 (구성: Debug/Stage/Release 3개 — Stage 빼먹으면 CI가 깨짐) +xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Debug \ + -destination 'generic/platform=iOS Simulator' build + +# 모듈 테스트 (Swift Testing 기반) +xcodebuild -workspace Atcha.xcworkspace -scheme HomeFeature \ + -destination 'platform=iOS Simulator,name=iPhone 17' test +# 단일 테스트: -only-testing:HomeFeatureTests/HomeViewModelTests/viewDidLoad_success_transitionsLoadingToLoaded + +# 의존 그래프 확인 (graph.dot 생성, gitignore됨) +tuist graph --format dot --no-open + +# 레거시 빌드 — 디바이스 전용 (TMapSDK.framework가 arm64 디바이스 전용이라 시뮬레이터 빌드는 원래 불가) +xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \ + -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build +``` + +도구는 mise로 고정(`mise.toml`, Tuist 4.202). `bundle exec`은 로컬에서 동작하지 않음(시스템 ruby 2.6 ↔ Gemfile.lock의 bundler 4.0 비호환) — fastlane은 CI 전용으로 취급. + +### 알아야 할 함정 + +- **xcconfig 4개(Base/Dev/Stage/Live)는 gitignore돼 있고 없으면 `tuist generate`가 에러로 실패한다.** `Scripts/bootstrap.sh`가 빈 스탠드인을 만들어 해결한다(CI는 시크릿에서 실제 파일을 복원). AtchaV2는 xcconfig에 의존하지 않도록 설계돼 있으므로 새 모듈에 xcconfig 참조를 추가하지 말 것. +- 빌드 구성은 프로젝트 전체가 **Debug/Stage/Release 3개**. 새 타겟·외부 의존성 설정에 Stage가 누락되면 `-configuration Stage` 빌드가 조용히 깨진다(외부 SPM은 `Tuist/Package.swift`의 `PackageSettings.baseSettings`가 3구성을 선언). +- SPM 의존성 추가는 `Tuist/Package.swift`에서. `Tuist/Package.resolved`는 커밋 대상(Amplitude가 branch 추적이라 리비전 고정 역할). +- 레거시 소스 글롭에서 `Atcha-iOS/App/DIContainer/DIContainer.swift`는 의도적으로 제외(기존 pbxproj도 컴파일하지 않던 죽은 파일, AppDIContainer 중복 선언). + +## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처) + +``` +AtchaV2(앱, 조합 루트) ─► HomeFeature ─► {HomeFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + └─► AtchaData ─► {Domain, CoreNetwork} +``` + +의존 규칙(위반 금지, `tuist graph`로 검증 가능): +- **Presentation → Domain ← Data**: Feature 모듈은 `AtchaData`를 절대 import하지 않는다. Domain은 무의존. +- 구체 Data/Network 타입을 보는 곳은 앱의 `AppDIContainer`(조합 루트)뿐. 여기서 NetworkClient → RepositoryImpl → UseCase → 피처 DIContainer 순으로 주입한다. +- Firebase 등 외부 라이브러리는 **앱 타겟에서만** 링크(내부 모듈 전부 static framework, 중복 심볼 방지). 앱 타겟에 `-ObjC` 필요. + +모듈 정의는 `Tuist/ProjectDescriptionHelpers/`의 DSL로만 한다: +- `Project.feature(name:)` — 피처당 `{N}Feature`/`{N}FeatureInterface`/`{N}FeatureTests`/`{N}FeatureExample` 4타겟. 새 피처는 `Projects/Feature/Home`을 그대로 본뜬다. +- `Project.layer(name:)` — 수평 모듈(framework+tests). isolation 파라미터: UI 모듈은 `.mainActor`, Domain/Data/Network은 `.nonisolated`. +- `Settings.atchaV2()` — Swift 6 + 3구성 + 구성별 컴파일 플래그(Debug=DEV, Stage=STAGE, Release=LIVE). 환경 분기는 앱의 `AppEnvironment` enum이 이 플래그로 수행(런타임 xcconfig 의존 없음). + +### 피처 내부 컨벤션 (Home이 표준 템플릿) + +- 클린아키텍처 수직 슬라이스 필수 구성: Domain에 Entity + **추상화 UseCase(프로토콜)** + Repository 인터페이스 / Data에 Request·Response DTO + `toEntity()` + RepositoryImpl / Presentation에 ViewData(Entity를 뷰에 직접 노출 금지) + ViewModel + VC. +- **모든 ViewModel은 `@MainActor`.** 비동기 작업은 `Task` 보관 + `deinit`에서 cancel + `[weak self]` + `Task.isCancelled` 가드. +- **조립은 피처 DIContainer, 화면 흐름은 Coordinator.** Coordinator는 `CoreCoordinator.Coordinator`를 채택하고 `finishDelegate`(weak)로 부모가 자식을 제거한다(누수 방지). navigationController는 앱 루트(AppCoordinator)만 강한 소유, 나머지는 weak. +- 다른 모듈에 노출하는 진입점은 Interface 타겟의 프로토콜(`HomeCoordinatorBuildable` 패턴)로만. +- 테스트는 **Swift Testing**(`@Test`/`#expect`). Example 앱은 스텁 UseCase로 피처 단독 실행(Data 무의존). 스텁이 Tests/Example에 중복되는 것은 의도된 트레이드오프. +- catch-all `Shared`/`Common` 모듈을 만들지 않는다. 로깅·캐싱 등이 필요해지면 목적별 단일 모듈(`Logger`, `Storage`)을 새로 판다. +- 모듈명 `Data`는 금지(Foundation.Data 섀도잉) — Data 레이어 모듈명은 `AtchaData`(디렉터리는 `Projects/Data`). +- UI는 UIKit 코드 기반(스토리보드 없음) + SnapKit + DesignSystem 토큰(`DSColor`/`DSFont`/`DSSpacing`). + +### 미완 상태 (작업 시 참고) + +- `AppEnvironment`의 API base URL은 플레이스홀더 — 실서버 주소 미정. +- `com.atcha.iOS.v2`용 GoogleService-Info.plist 미발급 — `AppDelegate`가 파일 존재를 가드한 뒤에만 `FirebaseApp.configure()` 호출. plist를 `Projects/App/Resources/`에 넣으면 자동 활성화. +- AtchaV2는 iOS 26 전용(AlarmKit 사용 예정). AlarmKit의 커스텀 알람 UI(Live Activity)는 추후 위젯 익스텐션 타겟이 별도로 필요. diff --git a/docs/prompts/atcha-v2-master-prompt.md b/docs/prompts/atcha-v2-master-prompt.md new file mode 100644 index 0000000..4f2b6e5 --- /dev/null +++ b/docs/prompts/atcha-v2-master-prompt.md @@ -0,0 +1,389 @@ +# AtchaV2 마스터 구현 프롬프트 — 막차 검색 + 알람 + +> **사용법**: 이 문서 전체를 Claude Code에 컨텍스트로 전달하고 `"Phase N을 진행해"`라고 지시한다. +> 실행 에이전트는 반드시 [진행 프로토콜](#진행-프로토콜)을 따르며, 한 번에 한 Phase만 수행한다. +> 작성일: 2026-08-22. 이 문서와 레포 `CLAUDE.md`가 충돌하면 **CLAUDE.md가 우선**한다. + +--- + +## Goal (최상위) + +**앗차 2.0의 핵심 가치를 완성한다: 오차를 감안하더라도, 가장 간편하고 쉽게 막차 시간을 알려주고 놓치지 않게 깨워주는 앱.** + +사용자 플로우 전체: + +``` +스플래시(자동 익명 인증, 로그인 UI 없음) + → 홈 (출발지=현재 위치 기본값 / 도착지 입력) + → 검색 화면 (장소 검색 + 최근 검색 → 서버 기준 "가장 늦은 차" 1개 + 더보기로 대안 경로) + → 경로 선택 → 홈 복귀 (선택 경로 카드 표출) + → 알람 등록 (AlarmKit, 단일 알람 — 새 경로 등록 시 교체) + → 홈 상단 배너 "막차 출발까지 N분" (1분 타이머 + 포그라운드 복귀 시 재조회) + → 서버가 알람 시각 재계산 시 FCM 사일런트 푸시(또는 폴링 폴백)로 알람 자동 갱신 +``` + +확정된 제품 결정사항 (변경하려면 사용자에게 먼저 물을 것): + +| 항목 | 결정 | +|---|---| +| 서버 | 레거시 1.x와 **같은 서버** — 스펙 확정·구현됨. 장소 검색도 자체 서버 | +| 인증 | 자동 **익명 인증** (로그인 화면 없음, 스플래시에서 토큰 부트스트랩) | +| 출발지 | **현재 위치 기본값** + 위치 권한 플로우 (denied 시 검색 유도) | +| 알람 | AlarmKit **기본 UI**, **단일 알람만** (새 경로 등록 시 기존 알람 교체). Live Activity 위젯 익스텐션은 스코프 제외 | +| 알람 시각 | **서버가 계산·갱신**. 통지: FCM 사일런트 푸시 + 폴링 폴백 | +| 홈 배너 | "막차 출발까지 N분" — 1분 단위 타이머 갱신 + 포그라운드 복귀 시 서버 재조회 | +| 결과 표출 | 기본 "가장 늦은 차" 1개 강조 → "더보기"로 대안 경로 목록 확장 | +| 검색 편의 | **최근 검색만** 로컬 저장 (즐겨찾기 없음, 서버 저장 아님) | +| 막차 없음 UX | 서버 상태를 3가지로 정규화: 막차 있음 / 오늘 막차 종료(다음 운행 안내) / 경로 없음(안내 문구) | +| 설명글 2종 | "막차 시간과 가까워질수록 정확해져요" / "알람 시간은 막차 환경에 따라 변경될 수 있어요" — 홈에 상시 노출 | +| 디자인 | 기존 V2 DesignSystem을 고도화해서 사용 (레거시 `DesignSource/`는 시각 스펙 참고만) | + +--- + +## 공통 규칙 (모든 Phase에 상속) + +### 시작 절차 + +모든 Phase 시작 전에 반드시: + +1. 레포 루트 `CLAUDE.md`를 읽는다 (아키텍처 규약·함정 목록의 원본). +2. 표준 템플릿을 읽는다: `Projects/Feature/Home/`(피처 구조), `Tuist/ProjectDescriptionHelpers/`(모듈 DSL). +3. 해당 Phase의 "레거시 참고 파일"을 읽는다 (아래 표). + +### 공통 acceptance (모든 Phase 마지막에 전부 실행) + +```bash +# 매니페스트(Project.swift/Workspace.swift) 수정한 경우에만 +tuist generate --no-open + +# 항상: Debug와 Stage 모두 빌드 (Stage 누락이 이 레포 1순위 함정) +xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Debug \ + -destination 'generic/platform=iOS Simulator' build +xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Stage \ + -destination 'generic/platform=iOS Simulator' build + +# 해당 Phase에서 만들거나 수정한 모듈의 테스트 +xcodebuild -workspace Atcha.xcworkspace -scheme <모듈명> \ + -destination 'platform=iOS Simulator,name=iPhone 17' test +``` + +### 공통 constraints + +- **레거시 보호**: `Atcha-iOS.xcodeproj`와 `Atcha-iOS/` 소스는 읽기 전용. 절대 수정·삭제 금지 (CI/fastlane이 직접 빌드 중). +- **xcconfig 참조 금지**: 새 모듈·타겟에 xcconfig 의존을 추가하지 않는다. 환경 분기는 `AppEnvironment` 컴파일 플래그(DEV/STAGE/LIVE)로만. +- **의존 규칙**: Feature는 `AtchaData`를 절대 import하지 않는다. Domain은 무의존. 구체 Data/Network 타입은 `AppDIContainer`(조합 루트)만 본다. `tuist graph --format dot --no-open`으로 검증 가능. +- **외부 라이브러리는 앱 타겟에서만 링크** (내부 모듈 전부 static framework — 중복 심볼 방지). 내부 모듈에서 `import Firebase*` 금지. +- **`Tuist/Package.swift`·`Tuist/Package.resolved` 무변경**: 필요한 SPM(Firebase 3종, SnapKit)은 이미 선언·링크돼 있다. 새 의존성이 필요해 보이면 멈추고 사용자에게 물을 것. +- **Swift 6 + isolation**: 모든 ViewModel `@MainActor`, 비동기는 `Task` 보관 + `deinit`에서 cancel + `[weak self]` + `Task.isCancelled` 가드. Domain/AtchaData/CoreNetwork 및 신규 Core 모듈은 `.nonisolated` — `@MainActor` 어노테이션 유입 금지. +- **테스트는 Swift Testing** (`@Test`/`#expect`). XCTest 금지. +- **모듈 규율**: catch-all `Shared`/`Common` 금지 (목적별 단일 모듈). 모듈명 `Data` 금지 (Foundation.Data 섀도잉 — Data 레이어는 `AtchaData`). +- **UI**: UIKit 코드 기반(스토리보드 없음) + SnapKit + DS 토큰(`DSColor`/`DSFont`/`DSSpacing`) 경유. 색·폰트·간격 하드코딩 금지. +- **신규 프로젝트는 `Workspace.swift` 등록 필수** (누락 시 스킴이 생성되지 않는다). +- 레거시 코드는 **의미만 이식**한다: Alamofire·RxSwift 등 레거시 의존이 섞인 코드 복붙 금지 (V2는 URLSession + Swift Concurrency). + +### 목표 모듈 지도 (전 Phase 완료 시점) + +``` +AtchaV2 (앱, 조합 루트: 어댑터/FCM/스플래시) + ├─► HomeFeature ──► {HomeFeatureInterface, SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► SearchFeature ─► {SearchFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit} + ├─► AtchaData ────► {Domain, CoreNetwork, CoreStorage} + ├─► CoreAuth ─────► {CoreNetwork, CoreStorage} ← import하는 곳은 App뿐 + ├─► CoreAlarm (무의존, AlarmKit 유일 import 지점) ← import하는 곳은 App뿐 + └─► CoreStorage / CoreNetwork / CoreCoordinator / DesignSystem / Domain(무의존) +``` + +신규 모듈 4개: `SearchFeature`(`Project.feature()`), `CoreStorage`·`CoreAuth`·`CoreAlarm`(`Project.layer()`, 전부 `.nonisolated`). + +디바이스 능력(위치·알람)은 **Domain 포트 + App 어댑터** 패턴: Domain에 순수 프로토콜(`LocationService`, `AlarmScheduler`)만 두고, CoreLocation/AlarmKit을 아는 어댑터는 `Projects/App/Sources/Adapters/`에 둔다. Feature는 UseCase 프로토콜만 본다. + +### 서버 계약 뼈대 (직접 수록 — 필드 상세는 레거시 파일 참조) + +모든 응답은 envelope로 감싸져 있다 (`Atcha-iOS/Core/Network/API/APIResponse.swift` 참고): + +```swift +struct APIResponse: Decodable { + let responseCode: ... // 정확한 타입·성공값은 레거시 파일에서 확인 + let result: T? +} +``` + +| 용도 | 엔드포인트 | 비고 | +|---|---|---| +| 막차 경로 검색 | `GET /routes/last-routes?startLat&startLon&endLat&endLon` (레거시 실측) | 결과 목록의 첫 항목이 "가장 늦은 차", 나머지가 더보기 대안 | +| 경로 상세 | `GET /routes/last-routes/{routeId}` | | +| 알람(사용자 경로) 등록/삭제/조회 | `POST` / `DELETE` / `GET /routes/user-routes` | 단일 알람 정책: 등록 전 기존 것 삭제 또는 서버 교체 규약 확인 | +| 알람 시각 갱신 조회 | `GET /routes/user-routes/refresh` | 폴링 폴백과 푸시 수신 후 재조회 공용 | +| 장소 키워드 검색 | `GET /locations` (keyword·좌표 쿼리) | | +| 역지오코딩 | `GET /locations/rgeo` (좌표 쿼리) | 현재 위치 → 출발지 라벨 | +| 토큰 리프레시 | `GET /auth/reissue` | 리프레시 토큰을 `Authorization: Bearer`로 전달 (레거시 실측) | + +쿼리 파라미터 이름·DTO 필드는 레거시 Repository/DTO 파일에서 확인해 이식한다. **이식 금지 목록**: SSE 스트림(`/routes/**/stream`), 서버 최근검색(`/locations/histories`, `/locations/history`), 실시간 도착(`/routes/user-routes/subway-arrival`, `bus-arrival`, `/transits/*`), 소셜 로그인(`/auth/login`, `/auth/sign-up`) — 전부 이번 스코프 밖. + +### 레거시 참고 파일 표 (읽기 전용) + +| 용도 | 경로 | +|---|---| +| 인증 의미 원본 (Bearer·공개경로·401 처리) | `Atcha-iOS/Core/Network/Token/TokenInterceptor.swift`, `TokenStorage.swift` | +| envelope·에러 규약 | `Atcha-iOS/Core/Network/API/APIResponse.swift`, `APIError.swift` | +| 막차 검색 API·DTO | `Atcha-iOS/Data/Repository/CourseRepositoryImpl.swift`, `Atcha-iOS/Data/Model/CourseSearchDTO/CourseSearchResponse.swift` | +| 알람 API·DTO | `Atcha-iOS/Data/Repository/AlarmRepositoryImpl.swift` | +| 장소 검색 API·DTO | `Atcha-iOS/Data/Repository/Location/` | +| 시각 스펙 참고 (컴포넌트) | `Atcha-iOS/DesignSource/` (AtchaTextField/AtchaList/AtchaToast 등) | +| V2 표준 템플릿 | `Projects/Feature/Home/`, `Tuist/ProjectDescriptionHelpers/` | + +--- + +## Phase 1 — 서버 계약 이식: Domain 확장 + AtchaData 실 엔드포인트 + +### Goal +막차·장소·알람의 Domain 계약(엔티티/포트/UseCase)과 실서버 Data 구현을 완성한다 — UI 없이, 기존 더미 흐름과 병행. + +### Requirements +- **Domain 엔티티**: `Place`(이름·주소·좌표), `LastRoute`(경로 요약 + `TransportLeg` 목록 + 막차 출발 시각), `AlarmInfo`(lastRouteId, 알람 시각, 막차 출발 시각, updatedAt), 정규화 열거형: + ```swift + public enum LastRouteSearchResult: Sendable, Equatable { + case available([LastRoute]) // 첫 항목 = 가장 늦은 차 + case serviceEnded // 오늘 막차 종료 + case noRoute // 경로 없음 (도보권 등) + } + ``` +- **Domain 포트**: `PlaceRepository`(키워드 검색·역지오코딩), `LastRouteRepository`, `AlarmRepository`(서버 등록/삭제/refresh 조회), `RecentSearchRepository`(로컬 — 구현은 Phase 2), `AlarmScheduler`·`LocationService`(디바이스 포트 — 구현은 Phase 6~7). +- **UseCase** (프로토콜 + Default 구현): `SearchPlacesUseCase`, `SearchLastRoutesUseCase`(**정규화 책임** — 응답 코드/빈 목록 → `serviceEnded`/`noRoute` 매핑), `RegisterAlarmUseCase`(서버 등록 성공 → `AlarmScheduler.replaceAlarm` 순서, 단일 알람 정책), `CancelAlarmUseCase`, `RefreshAlarmUseCase`, `GetCurrentLocationUseCase`, `RecentSearchesUseCase`. +- **AtchaData**: `RouteEndpoint`/`PlaceEndpoint`/`AlarmEndpoint`(기존 `HomeEndpoint.swift` 패턴), `APIResponse` envelope 디코딩 헬퍼, DTO는 위 레거시 파일에서 이식(optional 남발 정리, V2 네이밍, `toEntity()` 패턴), RepositoryImpl 구현. +- **테스트**: 레거시 응답 형태의 **인라인 JSON 문자열 픽스처**로 DTO 디코딩·`toEntity()`·정규화 테스트. UseCase는 스텁 리포지토리로 정책(등록 순서·정규화) 테스트. + +### Constraints +- 기존 더미(`HomeSummary`/`FetchHomeUseCase`/`HomeRepositoryImpl`/`HomeEndpoint`) **삭제·수정 금지** — Phase 6에서 일괄 제거한다. 지금 지우면 HomeFeature·App 빌드가 붕괴한다. +- 이식 금지 목록(공통 규칙) 준수. Alamofire 타입 유입 금지. +- 픽스처는 리소스 파일 대신 인라인 문자열 (테스트 타겟 리소스 설정 회피). + +### Acceptance +공통 acceptance + `-scheme Domain test` + `-scheme AtchaData test`. + +### 사람 검수 +엔드포인트·DTO 매핑 표를 보고용으로 출력하고 확인받을 것. 특히 **"막차 종료/경로 없음"을 서버가 어떻게 표현하는지(responseCode 값)는 [미확정 입력](#미확정-입력-사용자-제공-대기)** — 실측값을 받으면 정규화 로직에 반영. + +--- + +## Phase 2 — CoreStorage 모듈 + 최근 검색 로컬 저장 + +### Goal +목적별 저장 모듈(CoreStorage)을 신설하고 최근 검색 로컬 저장을 구현한다. + +### Requirements +- `Projects/Core/Storage`에 `Project.layer(name: "CoreStorage", bundleSuffix: "core.storage", isolation: .nonisolated)` + **Workspace.swift 등록**. +- `KeyValueStore` 프로토콜 + `UserDefaultsKeyValueStore` + `KeychainStore`(레거시 `TokenStorage.swift`의 키체인+메모리 캐시 패턴 참고, 프로토콜 기반 재작성 — Phase 3의 토큰 저장에 재사용) + Codable 저장 헬퍼. +- AtchaData에 `RecentSearchRepositoryImpl`(CoreStorage 사용): 최대 개수 제한(예: 10개), 중복 검색 시 최신으로 갱신, 최신순 정렬, 삭제 지원. `Projects/Data/Project.swift`에 CoreStorage 의존 추가. + +### Constraints +- 테스트는 **인메모리 `KeyValueStore` 스텁**으로 (실 UserDefaults 사용 금지 — 테스트 오염). +- 앱 타겟 의존성에 CoreStorage를 추가하지 않는다 (AtchaData 경유; App이 직접 필요해지는 건 Phase 3의 CoreAuth부터). + +### Acceptance +공통 acceptance + `-scheme CoreStorage test` + `-scheme AtchaData test`. + +--- + +## Phase 3 — CoreAuth: 익명 세션 + 인증 데코레이터 + 스플래시 + +### Goal +로그인 UI 없는 익명 인증 체계를 구축하고, 모든 API 호출에 토큰을 투명하게 부착한다. + +### Requirements +- `Projects/Core/Auth`에 `Project.layer(name: "CoreAuth", bundleSuffix: "core.auth", isolation: .nonisolated, dependencies: [CoreNetwork, CoreStorage])` + Workspace 등록. +- `TokenStore`(KeychainStore 주입, 액세스/리프레시 토큰 보관). +- `AuthSessionManager` **actor**: 익명 세션 부트스트랩(최초 실행 시 발급), `GET /auth/reissue` 리프레시(리프레시 토큰을 Bearer 헤더로 — 레거시 실측), **single-flight**(동시 다발 401에도 리프레시는 1회 — 레거시 `TokenInterceptor.swift`의 대기열 의미를 actor로 재구현). +- `AuthenticatedNetworkClient: NetworkClient` **데코레이터**: 기존 `URLSessionNetworkClient`를 감싸 Bearer 부착 → 401 시 리프레시 1회 → 재시도 → 리프레시도 실패하면 **익명 세션 재발급 후 재시도**. 공개 경로(인증 헤더 제외) 목록은 주입 가능하게. +- `AppDIContainer`에서 NetworkClient를 데코레이터로 교체 (교체 지점은 이 한 곳). +- `AppCoordinator`에 스플래시 단계: 로고 화면 → 인증 부트스트랩 완료 후 홈 진입, 실패 시 재시도 UI. +- `AppEnvironment`의 `apiBaseURL` 플레이스홀더를 실제 값으로 교체 ([미확정 입력](#미확정-입력-사용자-제공-대기)). + +### Constraints +- 레거시 `SessionController.expireAndRouteToLogin` 패턴 **이식 금지** — V2에는 로그인 화면이 없다. +- CoreAuth를 import하는 곳은 **App뿐** (`tuist graph`로 확인). +- **AtchaData·Feature 코드가 한 줄도 안 바뀌어야 정상** — 바뀐다면 데코레이터 설계가 틀린 것. +- 테스트: 스텁 NetworkClient로 401→리프레시→재시도, single-flight 동시성, 공개 경로 예외 검증. + +### Acceptance +공통 acceptance + `-scheme CoreAuth test`. + +### 사람 검수 (블로킹) +**실 base URL과 익명 인증 발급 엔드포인트 스펙은 코드 어디에도 없다** (레거시는 소셜 로그인뿐, xcconfig는 gitignore). 코드·테스트는 완성하되, **실서버 스모크 전에 반드시 사용자에게 두 값을 확인**받을 것. + +--- + +## Phase 4 — DesignSystem 고도화 *(Phase 1~3과 병렬 가능)* + +### Goal +검색·결과·알람 UI에 필요한 재사용 컴포넌트를 DesignSystem에 추가한다. + +### Requirements +- 컴포넌트 신설: `DSTextField`(검색 입력), `DSListCell`(장소/경로 리스트 셀 — 최근검색·검색결과·대안경로 공용), `DSRouteCard`(선택 경로 요약 카드), `DSBanner`(상단 카운트다운 배너), `DSToast`, `DSEmptyState`(막차 종료/경로 없음/권한 거부 공용 — 아이콘+제목+본문+선택적 액션 버튼). +- 설명글용 캡션 스타일(작은 안내 텍스트) 추가. +- 필요한 색·간격이 토큰에 없으면 **토큰부터 추가**하고 컴포넌트가 토큰을 쓰게 한다. +- 레거시 `Atcha-iOS/DesignSource/`는 **시각 스펙 참고용으로만** (코드 복붙 금지 — 레거시 컨벤션·의존이 다름). + +### Constraints +- `.mainActor` isolation 유지 (DesignSystem 기존 설정). +- 에셋은 `DesignSystem.xcassets` + 기존 `asset(_:fallback:)` 폴백 패턴 준수 (static framework의 번들 처리). +- 기존 `DSButton` API 호환 유지 (파괴적 변경 금지). + +### Acceptance +공통 acceptance + `-scheme DesignSystem test`. + +### 사람 검수 +레이어 모듈엔 Example 타겟이 없으므로 시각 검수는 Phase 5·6의 Example 앱에서 수행한다. + +--- + +## Phase 5 — SearchFeature 신규 + +### Goal +장소 검색 → 막차 결과("가장 늦은 차" + 더보기) → 경로 선택 반환까지의 검색 플로우를 독립 실행 가능한 피처로 만든다. + +### Requirements +- `Projects/Feature/Search`에 `Project.feature(name: "Search", ...)` 신설 (의존 구성은 `Projects/Feature/Home/Project.swift`를 그대로 본뜸) + Workspace 등록. +- Interface 타겟에 노출: + ```swift + @MainActor + public protocol SearchCoordinatorBuildable { + func makeSearchCoordinator( + navigationController: UINavigationController, + onRouteSelected: @escaping (LastRoute) -> Void + ) -> any Coordinator + } + ``` +- 화면 구성: + - 출발지·도착지 입력 슬롯 (`DSTextField`), 키워드 검색은 **디바운스 + 이전 Task cancel**. + - 최근 검색 리스트 (선택 시 즉시 적용, 스와이프/버튼 삭제). + - 두 지점 확정 시 막차 결과: 최상단 "가장 늦은 차" 강조(`DSRouteCard`) + "더보기" 탭 시 대안 경로 목록 확장(`DSListCell`). + - `serviceEnded`/`noRoute` 상태는 `DSEmptyState`로: 막차 종료 → "오늘 막차가 끊겼어요" + 다음 운행 안내(서버 제공 시)/재검색 유도, 경로 없음 → "대중교통 경로를 찾지 못했어요" + 재검색 유도. +- 경로 선택 → `onRouteSelected(entity)` 호출 + `finish()` (finishDelegate로 부모가 제거). +- ViewModel 테스트(스텁 UseCase: 성공/막차 종료/경로 없음/검색 실패), Example 앱은 스텁으로 전체 플로우 시연. + +### Constraints +- **AtchaData import 금지** — UseCase 프로토콜만. +- Interface 타겟에는 프로토콜 + 최소 타입만 (구현 유출 금지). +- Entity를 뷰에 직접 노출 금지 — ViewData로 감쌀 것. +- navigationController는 weak (앱 루트만 강한 소유). +- 스텁이 Tests/Example에 중복되는 것은 의도된 트레이드오프 (기존 규약). + +### Acceptance +공통 acceptance + `-scheme SearchFeature test` + `SearchFeatureExample` Debug 빌드. + +### 사람 검수 +`SearchFeatureExample`을 시뮬레이터에서 실행해 검색 UX(디바운스, 더보기 확장, 3가지 빈 상태) 시연. + +--- + +## Phase 6 — HomeFeature 개편 (더미 제거 + 위치 권한 + 검색 연결) + +### Goal +플레이스홀더 홈을 실제 홈으로 교체한다 — 현재 위치 출발지, 검색 진입, 선택 경로 표출, 알람 등록 버튼(로컬 스케줄은 아직 no-op). + +### Requirements +- **홈 화면**: 출발지(현재 위치 기본값 — `GetCurrentLocationUseCase` + 역지오코딩 라벨)/도착지 필드 → 탭 시 `SearchCoordinatorBuildable`로 검색 플로우 시작 → `onRouteSelected` 수신 시 경로 카드(`DSRouteCard`) 표출. +- **위치 권한 플로우**: 홈 최초 진입 시 WhenInUse 요청. denied → 출발지 빈 상태 + "출발지를 검색해 주세요" 유도 + 설정 이동 안내. `Projects/App/Project.swift` infoPlist에 `NSLocationWhenInUseUsageDescription` 추가. +- App에 `CoreLocationServiceAdapter`(CLLocationManager → Domain `LocationService`) 구현·주입 (`Projects/App/Sources/Adapters/`). +- **알람 등록 버튼**: `RegisterAlarmUseCase` 호출 — 서버 등록은 실동작, `AlarmScheduler`는 App의 `NoopAlarmScheduler` 임시 어댑터 (Phase 7에서 교체). +- **설명글 2종** 상시 노출 (캡션 스타일): "막차 시간과 가까워질수록 정확해져요" / "알람 시간은 막차 환경에 따라 변경될 수 있어요". +- **상단 배너**: 알람 등록 상태면 `DSBanner`에 "막차 출발까지 N분" — 1분 단위 타이머 갱신 (실서버 갱신 연동은 Phase 7~8). +- **더미 일괄 청소**: `HomeSummary`·`FetchHomeUseCase`·`HomeRepository(+Impl)`·`HomeEndpoint`·`HomeSummaryRequestDTO/ResponseDTO`·관련 테스트 제거. `HomeDIContainer`·`AppDIContainer` 재구성. `Projects/Feature/Home/Project.swift`에 `SearchFeatureInterface` 의존 추가 + `tuist generate`. + +### Constraints +- 더미 제거는 **이 Phase에서 일괄** (부분 제거 시 App 빌드 붕괴). +- HomeFeature가 SearchFeature **본체를 import하면 안 됨** — Interface만 (`tuist graph`로 확인). +- 타이머는 ViewModel이 Task로 보관 + `deinit` cancel. +- Example 앱은 스텁 LocationService·UseCase로 실행 가능해야 함. + +### Acceptance +공통 acceptance + `-scheme HomeFeature test` + `HomeFeatureExample` Debug 빌드 + `tuist graph`로 의존 규칙 확인. + +### 사람 검수 (중요) +시뮬레이터에서 **스플래시 → 홈 → 검색 → 경로 선택 → 홈 복귀 → (Noop) 알람 등록 → 배너 표시** 전체 플로우 시연 후 확인받을 것. + +--- + +## Phase 7 — CoreAlarm: AlarmKit 스케줄링 E2E + +### Goal +AlarmKit 래퍼 모듈을 만들고 알람 등록을 실제 디바이스 알람까지 연결한다 (단일 알람 교체 정책). + +### Requirements +- `Projects/Core/Alarm`에 `Project.layer(name: "CoreAlarm", bundleSuffix: "core.alarm", isolation: .nonisolated)` (무의존 — Domain을 import하지 않는다) + Workspace 등록. **AlarmKit import는 이 모듈이 유일.** +- 중립 타입 API: + ```swift + public struct AlarmSpec: Sendable { public let id: String; public let fireDate: Date; public let title: String } + public protocol AlarmKitScheduling: Sendable { + func requestAuthorization() async -> Bool + func replaceAlarm(_ spec: AlarmSpec) async throws // 전부 취소 후 등록 (단일 알람 정책) + func cancelAll() async + func scheduledAlarm() async -> AlarmSpec? + } + ``` +- App의 `NoopAlarmScheduler`를 CoreAlarm 기반 어댑터(CoreAlarm ↔ Domain `AlarmScheduler` 매핑)로 교체. +- 등록 플로우 완성: 버튼 탭 → **AlarmKit 권한 요청(이 시점이 최초)** → 서버 등록 → 로컬 스케줄 → 배너 표시. 권한 denied → 토스트 + 설정 이동 안내, 서버 등록 보류. +- 알람 해제 플로우: `CancelAlarmUseCase` → 서버 삭제 + 로컬 취소 + 배너 숨김. +- 포그라운드 복귀 시 `RefreshAlarmUseCase` 재조회 → 시각 변경 시 재스케줄 + 배너 갱신 (SceneDelegate → 알림 경유). + +### Constraints +- AlarmKit 심볼이 Domain·Feature로 새어나가면 안 됨 (어댑터는 App에만). +- 단위 테스트는 AlarmKit 직접 호출 없이 — 교체·순서 정책은 Domain UseCase를 스텁 스케줄러로 테스트. +- **entitlements**: 현재 `NSAlarmKitUsageDescription`만 있고 entitlements 파일이 없다. AlarmKit 권한 요청이 실패하면 `Projects/App/Project.swift`에 Tuist `entitlements:` DSL로 추가 (수동 파일 생성 대신 매니페스트로). +- iOS 26 전용 API — 가용성 어노테이션 불필요 (배포 타겟이 이미 26.0). + +### Acceptance +공통 acceptance + `-scheme CoreAlarm test` + `-scheme Domain test`(정책 테스트). + +### 사람 검수 (블로킹) +**권한 다이얼로그와 실제 알람 발화는 자동 검증 불가** — iOS 26 시뮬레이터/실기기에서 사용자가 직접 확인해야 다음 Phase 진행. + +--- + +## Phase 8 — 갱신 채널: FCM 사일런트 푸시(가드) + 폴링 폴백 + 하드닝 + +### Goal +서버발 알람 시각 갱신을 FCM(가능할 때)·폴링(항상)으로 반영하고, 전체 시나리오를 마감한다. + +### Requirements +- **AppDelegate**: 기존 `GoogleService-Info.plist` 존재 가드 패턴 유지. 구성 성공 시에만 `MessagingDelegate` 설정 + `registerForRemoteNotifications()` 호출 (**알림 권한 프롬프트 없음** — 사일런트 푸시는 사용자 알림 권한 불필요). +- `didReceiveRemoteNotification`(content-available=1) 수신 → refresh 조회 → 재스케줄·배너 갱신. +- `Projects/App/Project.swift` infoPlist에 `UIBackgroundModes: ["remote-notification"]` 추가. +- **`AlarmSyncService`(App)로 갱신 일원화**: 앱 시작·포그라운드 복귀·푸시 수신 3경로가 전부 같은 `RefreshAlarmUseCase`를 경유하게 리팩터링 (Phase 7의 복귀 로직 흡수). +- FCM 토큰 서버 전달: 방식이 [미확정 입력](#미확정-입력-사용자-제공-대기) — 스펙 확인 후 구현, 그전까지는 토큰 로깅만. +- **하드닝 체크리스트** (전부 점검·수정): 막차 종료/경로 없음 UX, 위치·AlarmKit 권한 거부, 오프라인(네트워크 에러 시 토스트+재시도), 알람 교체(기존 알람 있는 상태에서 새 경로 등록), 배너 시각과 실제 알람 시각 일치, plist 부재 시 FCM 경로 완전 비활성. + +### Constraints +- **plist 부재 상태에서 전 acceptance 통과가 기본선** — FCM 코드는 전부 dead-path여야 하고, 폴링만으로 전 기능이 성립해야 한다. +- 사일런트 푸시에 `UNUserNotificationCenter.requestAuthorization` 호출 금지. +- 내부 모듈 Firebase import 금지, `Tuist/Package.swift`·`Package.resolved` 무변경 확인 (git diff로). + +### Acceptance +공통 acceptance + **Release 구성 빌드 1회 추가** + 전 모듈 테스트 스킴 일괄(`Domain`/`AtchaData`/`CoreStorage`/`CoreAuth`/`CoreAlarm`/`DesignSystem`/`SearchFeature`/`HomeFeature`) + `tuist graph`로 최종 의존 규칙 검증(모듈 지도와 일치). + +### 사람 검수 +`GoogleService-Info.plist` 발급 시 `Projects/App/Resources/`에 투입 → Firebase 자동 활성·푸시 수신 확인 (발급 전엔 폴링만으로 시연). + +--- + +## 진행 프로토콜 + +1. **한 번에 한 Phase만.** 사용자가 지정한 Phase의 시작 절차(공통 규칙)부터 수행한다. +2. Phase 진행 중 **뒤 Phase의 산출물을 선취하지 않는다** (예: Phase 1에서 더미 제거, Phase 5에서 홈 연결). +3. Phase 완료 시 **acceptance 명령을 전부 실행하고 결과를 그대로 보고**한다 (실패를 숨기지 않는다). +4. **사람 검수 포인트가 있으면 정지**하고 확인을 요청한다. "블로킹" 표시가 있으면 확인 전 다음 Phase 진행 금지. +5. 이 문서의 결정사항과 다른 방향이 필요해 보이면 **임의로 바꾸지 말고 근거와 함께 사용자에게 물을 것.** +6. [미확정 입력](#미확정-입력-사용자-제공-대기)이 필요한 시점이 오면 사용자에게 요청하고, 받기 전까지는 명시된 임시 동작(플레이스홀더·로깅)으로 진행한다. +7. Phase 4(DesignSystem)는 Phase 1~3과 독립이므로 병렬(먼저) 실행 가능. 그 외에는 번호 순서가 기본값. + +## 미확정 입력 (사용자 제공 대기) + +| # | 항목 | 필요한 Phase | 받기 전 임시 동작 | +|---|---|---|---| +| 1 | 실서버 base URL (Dev/Stage/Live) | 3 | `AppEnvironment` 플레이스홀더 유지, 실서버 스모크 보류 | +| 2 | 익명 인증 발급 엔드포인트 스펙 (레거시엔 소셜 로그인뿐) | 3 | 프로토콜·스텁으로 구현, 실호출 보류 | +| 3 | "막차 종료"/"경로 없음"의 서버 표현 (responseCode 실측값) | 1 | 빈 목록 = `serviceEnded` 가정, TODO 주석 | +| 4 | 알람 등록 API의 단일 알람 규약 (서버가 교체? 클라가 삭제 후 등록?) | 1, 7 | 클라가 삭제 후 등록으로 가정, TODO 주석 | +| 5 | FCM 토큰 서버 전달 방식 (익명 체계에서) | 8 | 토큰 로깅만, 전달 보류 | +| 6 | `com.atcha.iOS.v2`용 GoogleService-Info.plist | 8 | plist 가드로 FCM 비활성, 폴링만 동작 | From 473cea126dd78378378bb1025f6b75ac2c96e1b7 Mon Sep 17 00:00:00 2001 From: AppGoni Date: Sat, 22 Aug 2026 14:35:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20AtchaV2=20Phase=203=20=E2=80=94=20C?= =?UTF-8?q?oreAuth=20=EC=9D=B5=EB=AA=85=20=EC=84=B8=EC=85=98=20+=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=8D=B0=EC=BD=94=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20+=20=EC=8A=A4=ED=94=8C=EB=9E=98=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoreAuth 모듈 신설(.nonisolated, CoreNetwork+CoreStorage 의존) - TokenStore: 키체인 기반 액세스/리프레시 토큰 보관(레거시 키 계약 유지) - AuthSessionManager(actor): 익명 부트스트랩 + GET /auth/reissue 리프레시 (토큰 회전) + 401 복구 체인, single-flight - AuthenticatedNetworkClient: Bearer 부착 → 401 시 복구 1회 → 재시도, 공개 경로 suffix 주입, 복구 불능 시 원 401 전파 (AtchaData/Feature 무변경) - App: 데코레이터 주입(AppDIContainer), 스플래시 단계(AppCoordinator + SplashViewController), base URL 실호스트 반영(atcha.p-e.kr / atcha.online) - 익명 발급 엔드포인트는 스펙 미정(미확정 입력 #2) — 프로토콜+스텁, 실호출 보류 - 테스트 30개(Swift Testing): 401 체인·동시 401 리프레시 1회·공개 경로 예외 Co-Authored-By: Claude Fable 5 --- Projects/App/Project.swift | 2 + Projects/App/Sources/AppCoordinator.swift | 35 +++ Projects/App/Sources/AppDIContainer.swift | 19 +- Projects/App/Sources/AppEnvironment.swift | 10 +- .../App/Sources/SplashViewController.swift | 72 ++++++ Projects/Core/Auth/Project.swift | 12 + .../Sources/AnonymousSessionIssuing.swift | 27 +++ Projects/Core/Auth/Sources/AuthError.swift | 9 + .../Auth/Sources/AuthSessionManager.swift | 82 +++++++ .../Sources/AuthenticatedNetworkClient.swift | 86 +++++++ .../Core/Auth/Sources/ReissueEndpoint.swift | 24 ++ Projects/Core/Auth/Sources/TokenStore.swift | 44 ++++ .../Auth/Tests/AuthSessionManagerTests.swift | 219 +++++++++++++++++ Projects/Core/Auth/Tests/AuthTestStubs.swift | 130 ++++++++++ .../AuthenticatedNetworkClientTests.swift | 222 ++++++++++++++++++ .../Core/Auth/Tests/TokenStoreTests.swift | 49 ++++ Workspace.swift | 1 + 17 files changed, 1038 insertions(+), 5 deletions(-) create mode 100644 Projects/App/Sources/SplashViewController.swift create mode 100644 Projects/Core/Auth/Project.swift create mode 100644 Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift create mode 100644 Projects/Core/Auth/Sources/AuthError.swift create mode 100644 Projects/Core/Auth/Sources/AuthSessionManager.swift create mode 100644 Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift create mode 100644 Projects/Core/Auth/Sources/ReissueEndpoint.swift create mode 100644 Projects/Core/Auth/Sources/TokenStore.swift create mode 100644 Projects/Core/Auth/Tests/AuthSessionManagerTests.swift create mode 100644 Projects/Core/Auth/Tests/AuthTestStubs.swift create mode 100644 Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift create mode 100644 Projects/Core/Auth/Tests/TokenStoreTests.swift diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 5062c46..5e4857f 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -33,6 +33,8 @@ let appTarget = Target.target( .project(target: "Domain", path: "../Domain"), .project(target: "AtchaData", path: "../Data"), .project(target: "CoreNetwork", path: "../Core/Network"), + .project(target: "CoreStorage", path: "../Core/Storage"), + .project(target: "CoreAuth", path: "../Core/Auth"), .project(target: "CoreCoordinator", path: "../Core/Coordinator"), .project(target: "DesignSystem", path: "../DesignSystem"), .external(name: "FirebaseCore"), diff --git a/Projects/App/Sources/AppCoordinator.swift b/Projects/App/Sources/AppCoordinator.swift index a2ede25..3284cac 100644 --- a/Projects/App/Sources/AppCoordinator.swift +++ b/Projects/App/Sources/AppCoordinator.swift @@ -9,17 +9,52 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate { private let navigationController: UINavigationController private let container: AppDIContainer + private weak var splashViewController: SplashViewController? + private var bootstrapTask: Task? + init(navigationController: UINavigationController, container: AppDIContainer) { self.navigationController = navigationController self.container = container } + deinit { + bootstrapTask?.cancel() + } + func start() { + let splash = SplashViewController() + splash.onRetryTapped = { [weak self] in self?.bootstrap() } + splashViewController = splash + navigationController.setViewControllers([splash], animated: false) + bootstrap() + } + + private func bootstrap() { + bootstrapTask?.cancel() + splashViewController?.showLoading() + bootstrapTask = Task { [weak self] in + guard let self else { return } + do { + try await self.container.authSessionManager.bootstrap() + guard !Task.isCancelled else { return } + self.startHome() + } catch { + guard !Task.isCancelled else { return } + self.splashViewController?.showRetry() + } + } + } + + private func startHome() { let homeCoordinator = container.makeHomeDIContainer() .makeHomeCoordinator(navigationController: navigationController) homeCoordinator.finishDelegate = self addChild(homeCoordinator) homeCoordinator.start() + // Home pushed its own root; drop the splash out from under it. + if let splash = splashViewController { + navigationController.viewControllers.removeAll { $0 === splash } + } } func coordinatorDidFinish(_ coordinator: any Coordinator) { diff --git a/Projects/App/Sources/AppDIContainer.swift b/Projects/App/Sources/AppDIContainer.swift index ed118a2..44e27a4 100644 --- a/Projects/App/Sources/AppDIContainer.swift +++ b/Projects/App/Sources/AppDIContainer.swift @@ -1,5 +1,7 @@ import AtchaData +import CoreAuth import CoreNetwork +import CoreStorage import Domain import HomeFeature import HomeFeatureInterface @@ -8,11 +10,26 @@ import HomeFeatureInterface /// Presentation modules depend on Domain protocols only. final class AppDIContainer { private let networkClient: any NetworkClient + let authSessionManager: AuthSessionManager init() { - self.networkClient = URLSessionNetworkClient( + let baseClient = URLSessionNetworkClient( baseURL: AppEnvironment.current.apiBaseURL ) + let sessionManager = AuthSessionManager( + tokenStore: TokenStore(store: KeychainStore()), + // The plain client, not the decorator — reissue must never recurse + // into the 401-recovery path. + networkClient: baseClient, + // 미확정 입력 #2: swap in the real issuer here once the anonymous + // issuance endpoint spec is confirmed. + issuer: UnconfiguredAnonymousSessionIssuer() + ) + self.authSessionManager = sessionManager + self.networkClient = AuthenticatedNetworkClient( + base: baseClient, + sessionManager: sessionManager + ) } func makeHomeDIContainer() -> any HomeCoordinatorBuildable { diff --git a/Projects/App/Sources/AppEnvironment.swift b/Projects/App/Sources/AppEnvironment.swift index 334bf51..2b6a9d6 100644 --- a/Projects/App/Sources/AppEnvironment.swift +++ b/Projects/App/Sources/AppEnvironment.swift @@ -18,12 +18,14 @@ enum AppEnvironment { #endif } - // Placeholder URLs — replace with the real per-environment hosts. + // Hosts recovered from the legacy trust-evaluator registrations + // (user-approved 2026-08-22). Stage shares the dev host until a dedicated + // one exists. var apiBaseURL: URL { switch self { - case .dev: URL(string: "https://dev-api.atcha.example")! - case .stage: URL(string: "https://stage-api.atcha.example")! - case .live: URL(string: "https://api.atcha.example")! + case .dev: URL(string: "https://atcha.p-e.kr")! + case .stage: URL(string: "https://atcha.p-e.kr")! + case .live: URL(string: "https://atcha.online")! } } } diff --git a/Projects/App/Sources/SplashViewController.swift b/Projects/App/Sources/SplashViewController.swift new file mode 100644 index 0000000..56339f8 --- /dev/null +++ b/Projects/App/Sources/SplashViewController.swift @@ -0,0 +1,72 @@ +import DesignSystem +import UIKit + +/// Dumb splash screen: logo while the auth bootstrap runs, a retry affordance +/// when it fails. All flow decisions live in AppCoordinator. +final class SplashViewController: UIViewController { + var onRetryTapped: (() -> Void)? + + private let logoLabel = UILabel() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + private let retryStack = UIStackView() + private let messageLabel = UILabel() + private let retryButton = DSButton(title: "다시 시도") + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + } + + func showLoading() { + retryStack.isHidden = true + activityIndicator.startAnimating() + } + + func showRetry() { + activityIndicator.stopAnimating() + retryStack.isHidden = false + } + + private func configureUI() { + view.backgroundColor = DSColor.background + + logoLabel.text = "앗차" + logoLabel.font = DSFont.title(34) + logoLabel.textColor = DSColor.accent + + activityIndicator.hidesWhenStopped = true + + messageLabel.text = "네트워크 연결을 확인해주세요" + messageLabel.font = DSFont.body() + messageLabel.textColor = DSColor.textPrimary + messageLabel.textAlignment = .center + + retryButton.addAction( + UIAction { [weak self] _ in self?.onRetryTapped?() }, + for: .touchUpInside + ) + + retryStack.axis = .vertical + retryStack.alignment = .center + retryStack.spacing = DSSpacing.md + retryStack.isHidden = true + retryStack.addArrangedSubview(messageLabel) + retryStack.addArrangedSubview(retryButton) + + for subview in [logoLabel, activityIndicator, retryStack] { + subview.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(subview) + } + + NSLayoutConstraint.activate([ + logoLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -DSSpacing.xl), + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.topAnchor.constraint(equalTo: logoLabel.bottomAnchor, constant: DSSpacing.lg), + retryStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + retryStack.topAnchor.constraint(equalTo: logoLabel.bottomAnchor, constant: DSSpacing.lg), + retryStack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: DSSpacing.md), + retryStack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -DSSpacing.md), + ]) + } +} diff --git a/Projects/Core/Auth/Project.swift b/Projects/Core/Auth/Project.swift new file mode 100644 index 0000000..c9ba841 --- /dev/null +++ b/Projects/Core/Auth/Project.swift @@ -0,0 +1,12 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.layer( + name: "CoreAuth", + bundleSuffix: "core.auth", + isolation: .nonisolated, + dependencies: [ + .project(target: "CoreNetwork", path: "../Network"), + .project(target: "CoreStorage", path: "../Storage"), + ] +) diff --git a/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift b/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift new file mode 100644 index 0000000..c2f1a8e --- /dev/null +++ b/Projects/Core/Auth/Sources/AnonymousSessionIssuing.swift @@ -0,0 +1,27 @@ +public struct TokenPair: Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} + +/// Issues a brand-new anonymous session (first launch, or when refresh is no +/// longer possible). The real endpoint spec is pending (미확정 입력 #2); once it +/// lands, add a concrete implementation and swap it in AppDIContainer — no +/// other CoreAuth code changes. +public protocol AnonymousSessionIssuing: Sendable { + func issueSession() async throws -> TokenPair +} + +/// Stand-in until the issuance endpoint spec is confirmed: always throws +/// `AuthError.issuerNotConfigured`. +public struct UnconfiguredAnonymousSessionIssuer: AnonymousSessionIssuing { + public init() {} + + public func issueSession() async throws -> TokenPair { + throw AuthError.issuerNotConfigured + } +} diff --git a/Projects/Core/Auth/Sources/AuthError.swift b/Projects/Core/Auth/Sources/AuthError.swift new file mode 100644 index 0000000..01b6740 --- /dev/null +++ b/Projects/Core/Auth/Sources/AuthError.swift @@ -0,0 +1,9 @@ +public enum AuthError: Error, Equatable, Sendable { + /// The anonymous-session issuance endpoint spec is not confirmed yet + /// (master prompt, 미확정 입력 #2). `bootstrap()` treats this as non-fatal; + /// `recoverSession()` propagates it. + case issuerNotConfigured + /// The reissue envelope came back with a non-success responseCode or an + /// empty result. + case refreshRejected(responseCode: String) +} diff --git a/Projects/Core/Auth/Sources/AuthSessionManager.swift b/Projects/Core/Auth/Sources/AuthSessionManager.swift new file mode 100644 index 0000000..966232c --- /dev/null +++ b/Projects/Core/Auth/Sources/AuthSessionManager.swift @@ -0,0 +1,82 @@ +import CoreNetwork + +/// Owns the anonymous session lifecycle: first-launch issuance, token refresh +/// via GET /auth/reissue, and the 401-recovery chain. Single-flight is +/// guaranteed by keeping the in-progress recovery task on the actor — the +/// legacy interceptor's waiting-queue semantics without the queue. +public actor AuthSessionManager { + /// Read synchronously by the decorator on every request (no actor hop); + /// TokenStore is a Sendable value and the keychain store locks internally. + public nonisolated let tokenStore: TokenStore + + /// Must be the plain (undecorated) client — the legacy setup used a + /// separate interceptor-free session for reissue to break recursion. + private let networkClient: any NetworkClient + private let issuer: any AnonymousSessionIssuing + private var recoveryTask: Task? + + public init( + tokenStore: TokenStore, + networkClient: any NetworkClient, + issuer: any AnonymousSessionIssuing + ) { + self.tokenStore = tokenStore + self.networkClient = networkClient + self.issuer = issuer + } + + /// Splash-time bootstrap. Issues an anonymous session only when no access + /// token is stored. `issuerNotConfigured` is non-fatal (미확정 입력 #2's + /// interim behavior: proceed without tokens); other failures propagate so + /// the splash can offer retry. + public func bootstrap() async throws { + if (try? tokenStore.accessToken()) != nil { return } + do { + try tokenStore.save(try await issuer.issueSession()) + } catch AuthError.issuerNotConfigured { + return + } + } + + /// Single entry point for 401 recovery: refresh first, fall back to a + /// fresh anonymous session, throw when both are impossible. Concurrent + /// callers join the in-flight recovery instead of starting another. + public func recoverSession() async throws { + if let existing = recoveryTask { + return try await existing.value + } + let task = Task { try await self.performRecovery() } + recoveryTask = task + defer { recoveryTask = nil } + try await task.value + } + + private func performRecovery() async throws { + if let refreshToken = try? tokenStore.refreshToken() { + do { + return try await refreshTokens(with: refreshToken) + } catch { + // Refresh is dead (rejected, expired, transport) — fall back + // to re-issuing an anonymous session. + } + } + // Existing tokens are never cleared here: save() overwrites on + // success, and a transient failure must not destroy the anonymous + // identity that owns server-side state. + try tokenStore.save(try await issuer.issueSession()) + } + + private func refreshTokens(with refreshToken: String) async throws { + let envelope = try await networkClient.request( + ReissueEndpoint(refreshToken: refreshToken), + as: ReissueEnvelope.self + ) + guard envelope.responseCode == "SUCCESS", let tokens = envelope.result else { + throw AuthError.refreshRejected(responseCode: envelope.responseCode) + } + // The server rotates both tokens on reissue. + try tokenStore.save( + TokenPair(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) + ) + } +} diff --git a/Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift b/Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift new file mode 100644 index 0000000..83ebbf5 --- /dev/null +++ b/Projects/Core/Auth/Sources/AuthenticatedNetworkClient.swift @@ -0,0 +1,86 @@ +import CoreNetwork +import Foundation + +/// NetworkClient decorator that attaches the Bearer access token, and on a 401 +/// runs the session-recovery chain once before a single retry. Wrap the plain +/// URLSessionNetworkClient with this at the composition root — callers keep +/// seeing the NetworkClient contract, so no Data/Feature code changes. +public struct AuthenticatedNetworkClient: NetworkClient { + private let base: any NetworkClient + private let sessionManager: AuthSessionManager + /// Legacy allowlist semantics: suffix match on the request path skips the + /// Authorization header entirely. + private let publicPathSuffixes: [String] + + public init( + base: any NetworkClient, + sessionManager: AuthSessionManager, + publicPathSuffixes: [String] = ["/auth/reissue"] + ) { + self.base = base + self.sessionManager = sessionManager + self.publicPathSuffixes = publicPathSuffixes + } + + public func data(for endpoint: any Endpoint) async throws -> Data { + try await perform(endpoint) { try await base.data(for: $0) } + } + + public func request( + _ endpoint: any Endpoint, + as type: Response.Type + ) async throws -> Response { + try await perform(endpoint) { try await base.request($0, as: type) } + } + + private func perform( + _ endpoint: any Endpoint, + send: @Sendable (any Endpoint) async throws -> Value + ) async throws -> Value { + if publicPathSuffixes.contains(where: { endpoint.path.hasSuffix($0) }) { + return try await send(endpoint) + } + do { + return try await send(authorized(endpoint)) + } catch let networkError as NetworkError { + guard case .unacceptableStatus(code: 401, data: _) = networkError else { + throw networkError + } + do { + try await sessionManager.recoverSession() + } catch { + // Recovery is impossible — surface the original 401 so callers + // keep receiving the NetworkError contract, and skip the + // pointless unauthenticated retry (legacy doNotRetry). + throw networkError + } + // Single retry; a second 401 propagates as-is. + return try await send(authorized(endpoint)) + } + } + + /// No token (or an unreadable keychain) degrades to an unauthenticated + /// request — legacy semantics: never block the request itself. + private func authorized(_ endpoint: any Endpoint) -> any Endpoint { + guard let token = try? sessionManager.tokenStore.accessToken() else { + return endpoint + } + return AuthorizedEndpoint(base: endpoint, accessToken: token) + } +} + +/// Endpoint has no auth flag, so authorization is layered on by wrapping: all +/// members delegate to the base and only headers gain the Bearer entry +/// (overwriting on conflict, like the legacy interceptor's setValue). +struct AuthorizedEndpoint: Endpoint { + let base: any Endpoint + let accessToken: String + + var path: String { base.path } + var method: HTTPMethod { base.method } + var queryItems: [URLQueryItem] { base.queryItems } + var body: Data? { base.body } + var headers: [String: String] { + base.headers.merging(["Authorization": "Bearer \(accessToken)"]) { _, bearer in bearer } + } +} diff --git a/Projects/Core/Auth/Sources/ReissueEndpoint.swift b/Projects/Core/Auth/Sources/ReissueEndpoint.swift new file mode 100644 index 0000000..0eda11f --- /dev/null +++ b/Projects/Core/Auth/Sources/ReissueEndpoint.swift @@ -0,0 +1,24 @@ +import CoreNetwork + +/// Legacy-measured contract: GET /auth/reissue with the *refresh* token as the +/// Bearer credential. +struct ReissueEndpoint: Endpoint { + let refreshToken: String + + var path: String { "/auth/reissue" } + var method: HTTPMethod { .get } + var headers: [String: String] { ["Authorization": "Bearer \(refreshToken)"] } +} + +/// Deliberate duplicate of AtchaData's APIResponse envelope — CoreAuth must +/// not import AtchaData, so it carries its own minimal decoding type. +struct ReissueEnvelope: Decodable, Sendable { + let responseCode: String + let result: Tokens? + + struct Tokens: Decodable, Sendable { + let id: Int? + let accessToken: String + let refreshToken: String + } +} diff --git a/Projects/Core/Auth/Sources/TokenStore.swift b/Projects/Core/Auth/Sources/TokenStore.swift new file mode 100644 index 0000000..99dea1f --- /dev/null +++ b/Projects/Core/Auth/Sources/TokenStore.swift @@ -0,0 +1,44 @@ +import CoreStorage +import Foundation + +/// Keeps the session tokens under the legacy key names ("accessToken" / +/// "refreshToken") as raw UTF-8. The backing store is the v2 keychain service, +/// so there is no legacy migration concern — the names are kept only so the +/// stored contract stays recognizable. +public struct TokenStore: Sendable { + private enum Key { + static let accessToken = "accessToken" + static let refreshToken = "refreshToken" + } + + private let store: any KeyValueStore + + public init(store: any KeyValueStore) { + self.store = store + } + + public func accessToken() throws -> String? { + try string(forKey: Key.accessToken) + } + + public func refreshToken() throws -> String? { + try string(forKey: Key.refreshToken) + } + + /// Saves both tokens — the server rotates the refresh token on reissue, so + /// a pair is always written together. + public func save(_ pair: TokenPair) throws { + try store.set(Data(pair.accessToken.utf8), forKey: Key.accessToken) + try store.set(Data(pair.refreshToken.utf8), forKey: Key.refreshToken) + } + + public func clear() throws { + try store.removeValue(forKey: Key.accessToken) + try store.removeValue(forKey: Key.refreshToken) + } + + private func string(forKey key: String) throws -> String? { + guard let data = try store.data(forKey: key) else { return nil } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift new file mode 100644 index 0000000..3ec242e --- /dev/null +++ b/Projects/Core/Auth/Tests/AuthSessionManagerTests.swift @@ -0,0 +1,219 @@ +import CoreAuth +import CoreNetwork +import Foundation +import Testing + +struct AuthSessionManagerTests { + private let backing = InMemoryKeyValueStore() + private var tokenStore: TokenStore { TokenStore(store: backing) } + + private func makeManager( + handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data = { _, _ in Data() }, + issuer: StubIssuer = StubIssuer(result: .failure(AuthError.issuerNotConfigured)) + ) -> (manager: AuthSessionManager, network: ScriptedNetworkClient, issuer: StubIssuer) { + let network = ScriptedNetworkClient(handler: handler) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) + return (manager, network, issuer) + } + + // MARK: - bootstrap + + @Test + func bootstrap_emptyStore_issuesAndSavesTokens() async throws { + let issued = TokenPair(accessToken: "A", refreshToken: "R") + let (manager, _, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + + try await manager.bootstrap() + + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") + } + + @Test + func bootstrap_existingAccessToken_skipsIssuer() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _, issuer) = makeManager() + + try await manager.bootstrap() + + #expect(issuer.issueCallCount == 0) + #expect(try tokenStore.accessToken() == "A") + } + + /// 미확정 입력 #2's interim behavior: an unconfigured issuer is non-fatal — + /// the app proceeds without tokens. + @Test + func bootstrap_issuerNotConfigured_completesWithoutTokens() async throws { + let (manager, _, _) = makeManager() + + try await manager.bootstrap() + + #expect(try tokenStore.accessToken() == nil) + } + + @Test + func bootstrap_issuerTransportFailure_throws() async { + let (manager, _, _) = makeManager( + issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) + ) + + await #expect(throws: URLError.self) { + try await manager.bootstrap() + } + } + + // MARK: - recoverSession + + /// Pins the legacy-measured reissue contract: GET /auth/reissue with the + /// refresh token as the Bearer credential. + @Test + func recoverSession_withRefreshToken_sendsGetReissueWithBearerRefreshHeader() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network, _) = makeManager(handler: { _, _ in + reissueSuccessBody(access: "A2", refresh: "R2") + }) + + try await manager.recoverSession() + + let reissues = network.recordedCalls(to: "/auth/reissue") + #expect(reissues.count == 1) + #expect(reissues.first?.method == .get) + #expect(reissues.first?.headers["Authorization"] == "Bearer R") + } + + @Test + func recoverSession_reissueSuccess_rotatesBothTokens() async throws { + try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + let (manager, _, _) = makeManager(handler: { _, _ in + reissueSuccessBody(access: "A2", refresh: "R2") + }) + + try await manager.recoverSession() + + #expect(try tokenStore.accessToken() == "A2") + #expect(try tokenStore.refreshToken() == "R2") + } + + @Test + func recoverSession_reissueNonSuccessCode_fallsBackToIssuer() async throws { + try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + let issued = TokenPair(accessToken: "A2", refreshToken: "R2") + let (manager, _, issuer) = makeManager( + handler: { _, _ in reissueRejectedBody(responseCode: "AUTH_401") }, + issuer: StubIssuer(result: .success(issued)) + ) + + try await manager.recoverSession() + + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A2") + } + + @Test + func recoverSession_reissueHTTP401_fallsBackToIssuer() async throws { + try tokenStore.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + let issued = TokenPair(accessToken: "A2", refreshToken: "R2") + let (manager, _, issuer) = makeManager( + handler: { _, _ in throw unauthorizedError() }, + issuer: StubIssuer(result: .success(issued)) + ) + + try await manager.recoverSession() + + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A2") + } + + @Test + func recoverSession_noRefreshToken_skipsReissueAndUsesIssuer() async throws { + let issued = TokenPair(accessToken: "A", refreshToken: "R") + let (manager, network, issuer) = makeManager(issuer: StubIssuer(result: .success(issued))) + + try await manager.recoverSession() + + #expect(network.recorded.isEmpty) + #expect(issuer.issueCallCount == 1) + #expect(try tokenStore.accessToken() == "A") + } + + @Test + func recoverSession_refreshAndIssuerBothFail_throws() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _, _) = makeManager( + handler: { _, _ in throw unauthorizedError() }, + issuer: StubIssuer(result: .failure(URLError(.notConnectedToInternet))) + ) + + await #expect(throws: URLError.self) { + try await manager.recoverSession() + } + } + + /// Unlike bootstrap, recovery must not swallow issuerNotConfigured — the + /// decorator needs the failure to rethrow the original 401. + @Test + func recoverSession_issuerNotConfigured_propagatesError() async { + let (manager, _, _) = makeManager() + + await #expect(throws: AuthError.issuerNotConfigured) { + try await manager.recoverSession() + } + } + + /// No destructive clearing: a failed recovery keeps the stored identity. + @Test + func recoverSession_reissueFailure_keepsExistingTokensWhenIssuerAlsoFails() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, _, _) = makeManager(handler: { _, _ in throw unauthorizedError() }) + + await #expect(throws: AuthError.issuerNotConfigured) { + try await manager.recoverSession() + } + + #expect(try tokenStore.accessToken() == "A") + #expect(try tokenStore.refreshToken() == "R") + } + + /// Single-flight: while one recovery is parked mid-reissue, concurrent + /// callers join it instead of starting their own. + @Test + func recoverSession_concurrentCalls_performsSingleReissue() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let reissueReached = AsyncGate() + let reissueRelease = AsyncGate() + let (manager, network, _) = makeManager(handler: { _, _ in + reissueReached.open() + await reissueRelease.wait() + return reissueSuccessBody(access: "A2", refresh: "R2") + }) + + async let first: Void = manager.recoverSession() + await reissueReached.wait() + async let second: Void = manager.recoverSession() + async let third: Void = manager.recoverSession() + // The recovery task stays registered until the gate opens; give the + // joiners ample time to enter the actor before releasing. + try await Task.sleep(for: .milliseconds(50)) + reissueRelease.open() + + _ = try await (first, second, third) + + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + #expect(try tokenStore.accessToken() == "A2") + } + + @Test + func recoverSession_sequentialCalls_performsReissuePerCall() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (manager, network, _) = makeManager(handler: { _, index in + reissueSuccessBody(access: "A\(index + 2)", refresh: "R\(index + 2)") + }) + + try await manager.recoverSession() + try await manager.recoverSession() + + #expect(network.recordedCalls(to: "/auth/reissue").count == 2) + #expect(try tokenStore.accessToken() == "A3") + } +} diff --git a/Projects/Core/Auth/Tests/AuthTestStubs.swift b/Projects/Core/Auth/Tests/AuthTestStubs.swift new file mode 100644 index 0000000..8d0ba22 --- /dev/null +++ b/Projects/Core/Auth/Tests/AuthTestStubs.swift @@ -0,0 +1,130 @@ +import CoreAuth +import CoreNetwork +import CoreStorage +import Foundation +import Synchronization + +// Shared within this test target only (single-target sharing — distinct from +// the intended Tests/Example duplication trade-off). + +final class InMemoryKeyValueStore: KeyValueStore { + private let storage = Mutex<[String: Data]>([:]) + + func data(forKey key: String) throws -> Data? { storage.withLock { $0[key] } } + func set(_ data: Data, forKey key: String) throws { storage.withLock { $0[key] = data } } + func removeValue(forKey key: String) throws { storage.withLock { $0[key] = nil } } +} + +/// Records every request and delegates the response to a scripted handler. +/// The handler receives the call index *among calls to the same path*, so +/// "first /things call fails, second succeeds" scripts stay readable. +final class ScriptedNetworkClient: NetworkClient { + struct RecordedRequest: Sendable { + let path: String + let method: HTTPMethod + let headers: [String: String] + } + + private let handler: @Sendable (any Endpoint, Int) async throws -> Data + private let recordedStorage = Mutex<[RecordedRequest]>([]) + + init(handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data) { + self.handler = handler + } + + var recorded: [RecordedRequest] { recordedStorage.withLock { $0 } } + + func recordedCalls(to path: String) -> [RecordedRequest] { + recorded.filter { $0.path == path } + } + + func data(for endpoint: any Endpoint) async throws -> Data { + let pathCallIndex = recordedStorage.withLock { requests -> Int in + let index = requests.count(where: { $0.path == endpoint.path }) + requests.append(RecordedRequest( + path: endpoint.path, + method: endpoint.method, + headers: endpoint.headers + )) + return index + } + return try await handler(endpoint, pathCallIndex) + } + + func request( + _ endpoint: any Endpoint, + as _: Response.Type + ) async throws -> Response { + try JSONDecoder().decode(Response.self, from: try await data(for: endpoint)) + } +} + +final class StubIssuer: AnonymousSessionIssuing { + private let result: Result + private let callCount = Mutex(0) + + init(result: Result) { + self.result = result + } + + var issueCallCount: Int { callCount.withLock { $0 } } + + func issueSession() async throws -> TokenPair { + callCount.withLock { $0 += 1 } + return try result.get() + } +} + +/// Deterministic concurrency gate: `wait()` suspends until `open()`; once +/// opened, all current and future waiters pass immediately. +final class AsyncGate: Sendable { + private struct State { + var opened = false + var waiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) + + func wait() async { + await withCheckedContinuation { continuation in + let passImmediately = state.withLock { state -> Bool in + if state.opened { return true } + state.waiters.append(continuation) + return false + } + if passImmediately { continuation.resume() } + } + } + + func open() { + let waiters = state.withLock { state -> [CheckedContinuation] in + state.opened = true + let waiters = state.waiters + state.waiters = [] + return waiters + } + for waiter in waiters { waiter.resume() } + } +} + +struct TestEndpoint: Endpoint { + var path = "/things" + var method: HTTPMethod = .get + var headers: [String: String] = [:] +} + +func reissueSuccessBody(access: String, refresh: String) -> Data { + Data(""" + {"responseCode": "SUCCESS", "result": {"id": 1, "accessToken": "\(access)", "refreshToken": "\(refresh)"}} + """.utf8) +} + +func reissueRejectedBody(responseCode: String) -> Data { + Data(""" + {"responseCode": "\(responseCode)", "result": null} + """.utf8) +} + +func unauthorizedError() -> NetworkError { + .unacceptableStatus(code: 401, data: Data()) +} diff --git a/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift new file mode 100644 index 0000000..87449c9 --- /dev/null +++ b/Projects/Core/Auth/Tests/AuthenticatedNetworkClientTests.swift @@ -0,0 +1,222 @@ +import CoreAuth +import CoreNetwork +import Foundation +import Testing + +struct AuthenticatedNetworkClientTests { + private let backing = InMemoryKeyValueStore() + private var tokenStore: TokenStore { TokenStore(store: backing) } + + /// Real AuthSessionManager + scripted network — the master-prompt test + /// scenario ("stub NetworkClient, 401→refresh→retry chain") end to end. + /// The scripted client serves both roles: the decorator's base transport + /// and the manager's reissue transport. + private func makeSUT( + handler: @escaping @Sendable (any Endpoint, Int) async throws -> Data, + issuer: StubIssuer = StubIssuer(result: .failure(AuthError.issuerNotConfigured)), + publicPathSuffixes: [String] = ["/auth/reissue"] + ) -> (sut: AuthenticatedNetworkClient, network: ScriptedNetworkClient, issuer: StubIssuer) { + let network = ScriptedNetworkClient(handler: handler) + let manager = AuthSessionManager(tokenStore: tokenStore, networkClient: network, issuer: issuer) + let sut = AuthenticatedNetworkClient( + base: network, + sessionManager: manager, + publicPathSuffixes: publicPathSuffixes + ) + return (sut, network, issuer) + } + + @Test + func dataFor_withStoredAccessToken_attachesBearerHeader() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { _, _ in Data("ok".utf8) }) + + let result = try await sut.data(for: TestEndpoint()) + + #expect(result == Data("ok".utf8)) + #expect(network.recorded.first?.headers["Authorization"] == "Bearer A") + } + + @Test + func dataFor_withoutToken_sendsWithoutAuthorizationHeader() async throws { + let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + + _ = try await sut.data(for: TestEndpoint()) + + #expect(network.recorded.first?.headers["Authorization"] == nil) + } + + @Test + func dataFor_publicPathSuffix_skipsAuthorizationHeader() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT( + handler: { _, _ in Data() }, + publicPathSuffixes: ["/public/thing"] + ) + + _ = try await sut.data(for: TestEndpoint(path: "/api/public/thing")) + + #expect(network.recorded.first?.headers["Authorization"] == nil) + } + + @Test + func dataFor_on401_refreshesAndRetriesWithNewToken() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { endpoint, index in + switch (endpoint.path, index) { + case ("/things", 0): throw unauthorizedError() + case ("/things", _): return Data("ok".utf8) + default: return reissueSuccessBody(access: "NEW", refresh: "R2") + } + }) + + let result = try await sut.data(for: TestEndpoint()) + + #expect(result == Data("ok".utf8)) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + let sends = network.recordedCalls(to: "/things") + #expect(sends.count == 2) + #expect(sends.last?.headers["Authorization"] == "Bearer NEW") + } + + @Test + func dataFor_on401RetryAlso401_throwsWithoutSecondRefresh() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { endpoint, _ in + if endpoint.path == "/things" { throw unauthorizedError() } + return reissueSuccessBody(access: "NEW", refresh: "R2") + }) + + await #expect(throws: NetworkError.self) { + try await sut.data(for: TestEndpoint()) + } + + #expect(network.recordedCalls(to: "/things").count == 2) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + } + + /// Recovery failure surfaces the *original* 401 (NetworkClient contract), + /// not the internal auth error, and skips the unauthenticated retry. + @Test + func dataFor_on401RecoveryFails_rethrowsOriginalUnauthorized() async throws { + let marker = Data("original-401".utf8) + let (sut, network, _) = makeSUT(handler: { _, _ in + throw NetworkError.unacceptableStatus(code: 401, data: marker) + }) + + do { + _ = try await sut.data(for: TestEndpoint()) + Issue.record("Expected the original 401 to be rethrown") + } catch let error as NetworkError { + guard case .unacceptableStatus(code: 401, data: let data) = error else { + Issue.record("Expected unacceptableStatus(401), got \(error)") + return + } + #expect(data == marker) + } + + #expect(network.recordedCalls(to: "/things").count == 1) + } + + @Test + func dataFor_401RefreshFailsIssuerSucceeds_retriesWithIssuedToken() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "DEAD")) + let issued = TokenPair(accessToken: "ISSUED", refreshToken: "R2") + let (sut, network, issuer) = makeSUT( + handler: { endpoint, index in + switch (endpoint.path, index) { + case ("/things", 0): throw unauthorizedError() + case ("/things", _): return Data("ok".utf8) + default: throw unauthorizedError() // reissue rejected — refresh is dead + } + }, + issuer: StubIssuer(result: .success(issued)) + ) + + let result = try await sut.data(for: TestEndpoint()) + + #expect(result == Data("ok".utf8)) + #expect(issuer.issueCallCount == 1) + #expect(network.recordedCalls(to: "/things").last?.headers["Authorization"] == "Bearer ISSUED") + } + + @Test + func dataFor_non401Status_propagatesWithoutRecovery() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { _, _ in + throw NetworkError.unacceptableStatus(code: 500, data: Data()) + }) + + await #expect(throws: NetworkError.self) { + try await sut.data(for: TestEndpoint()) + } + + #expect(network.recordedCalls(to: "/things").count == 1) + #expect(network.recordedCalls(to: "/auth/reissue").isEmpty) + } + + @Test + func request_on401_refreshesRetriesAndDecodes() async throws { + struct Payload: Decodable, Equatable, Sendable { + let value: String + } + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { endpoint, index in + switch (endpoint.path, index) { + case ("/things", 0): throw unauthorizedError() + case ("/things", _): return Data(#"{"value": "hi"}"#.utf8) + default: return reissueSuccessBody(access: "NEW", refresh: "R2") + } + }) + + let payload = try await sut.request(TestEndpoint(), as: Payload.self) + + #expect(payload == Payload(value: "hi")) + #expect(network.recordedCalls(to: "/things").count == 2) + } + + @Test + func dataFor_endpointWithCustomHeaders_preservesThemAndAddsBearer() async throws { + try tokenStore.save(TokenPair(accessToken: "A", refreshToken: "R")) + let (sut, network, _) = makeSUT(handler: { _, _ in Data() }) + + _ = try await sut.data(for: TestEndpoint(headers: ["X-Custom": "1"])) + + let headers = network.recorded.first?.headers + #expect(headers?["X-Custom"] == "1") + #expect(headers?["Authorization"] == "Bearer A") + } + + /// Master prompt: "동시 다발 401에도 리프레시는 1회" — concurrent 401s share + /// one recovery. + @Test + func dataFor_concurrent401s_triggersSingleRefresh() async throws { + try tokenStore.save(TokenPair(accessToken: "OLD", refreshToken: "R")) + let reissueReached = AsyncGate() + let reissueRelease = AsyncGate() + let (sut, network, _) = makeSUT(handler: { endpoint, _ in + if endpoint.path == "/auth/reissue" { + reissueReached.open() + await reissueRelease.wait() + return reissueSuccessBody(access: "NEW", refresh: "R2") + } + // Old token → 401; refreshed token → success. + if endpoint.headers["Authorization"] == "Bearer OLD" { throw unauthorizedError() } + return Data("ok".utf8) + }) + + async let first = sut.data(for: TestEndpoint()) + async let second = sut.data(for: TestEndpoint()) + async let third = sut.data(for: TestEndpoint()) + await reissueReached.wait() + // The recovery is parked at the gate; give the remaining 401 callers + // time to join it before releasing. + try await Task.sleep(for: .milliseconds(50)) + reissueRelease.open() + + let results = try await [first, second, third] + + #expect(results.allSatisfy { $0 == Data("ok".utf8) }) + #expect(network.recordedCalls(to: "/auth/reissue").count == 1) + } +} diff --git a/Projects/Core/Auth/Tests/TokenStoreTests.swift b/Projects/Core/Auth/Tests/TokenStoreTests.swift new file mode 100644 index 0000000..fcad8c5 --- /dev/null +++ b/Projects/Core/Auth/Tests/TokenStoreTests.swift @@ -0,0 +1,49 @@ +import CoreAuth +import Foundation +import Testing + +struct TokenStoreTests { + private let backing = InMemoryKeyValueStore() + private var store: TokenStore { TokenStore(store: backing) } + + @Test + func save_thenRead_returnsBothTokens() throws { + try store.save(TokenPair(accessToken: "A", refreshToken: "R")) + + #expect(try store.accessToken() == "A") + #expect(try store.refreshToken() == "R") + } + + @Test + func accessToken_emptyStore_returnsNil() throws { + #expect(try store.accessToken() == nil) + #expect(try store.refreshToken() == nil) + } + + @Test + func save_overwrite_rotatesBothTokens() throws { + try store.save(TokenPair(accessToken: "A1", refreshToken: "R1")) + try store.save(TokenPair(accessToken: "A2", refreshToken: "R2")) + + #expect(try store.accessToken() == "A2") + #expect(try store.refreshToken() == "R2") + } + + @Test + func clear_thenRead_returnsNil() throws { + try store.save(TokenPair(accessToken: "A", refreshToken: "R")) + try store.clear() + + #expect(try store.accessToken() == nil) + #expect(try store.refreshToken() == nil) + } + + /// Pins the stored contract: legacy key names, raw UTF-8 values. + @Test + func save_usesLegacyKeychainKeys() throws { + try store.save(TokenPair(accessToken: "A", refreshToken: "R")) + + #expect(try backing.data(forKey: "accessToken") == Data("A".utf8)) + #expect(try backing.data(forKey: "refreshToken") == Data("R".utf8)) + } +} diff --git a/Workspace.swift b/Workspace.swift index 90ec6c2..415fb51 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -9,6 +9,7 @@ let workspace = Workspace( "Projects/Data", "Projects/Core/Network", "Projects/Core/Storage", + "Projects/Core/Auth", "Projects/Core/Coordinator", "Projects/DesignSystem", "Projects/Legacy",