diff --git a/CLAUDE.md b/CLAUDE.md index 32b69666..890adc86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,7 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho - `AppEnvironment.apiBaseURL`은 **세 환경 모두 `https://atcha.kro.kr/api` 단일값**(2026-09-24 확정). 이전 호스트 둘은 모두 죽었다 — `atcha.online`은 NXDOMAIN, `atcha.p-e.kr`은 443 연결 불가. **`/api` 접두어는 base URL에서만 붙인다** — 각 `Endpoint.path`는 접두어를 모르므로(`/auth/guest`, `/routes/user-routes` …) 서버가 접두어 체계를 바꾸면 이 프로퍼티 한 줄만 고친다. 환경별 호스트가 다시 생기면 `switch`로 되돌리면 된다. - **네트워크 진단**: `URLSessionNetworkClient`가 DEBUG에서 `com.atcha.network` 서브시스템에 `메서드 · URL · 상태코드/에러 · 소요시간`을 남긴다(헤더·본문은 토큰이 실리므로 절대 로깅 금지). `NetworkError.debugDescription`이 URLError 코드를 노출해 타임아웃(-1001)·연결 불가(-1004)·TLS 실패(-1200)를 가른다 — 이전에는 전부 `.transport`로 뭉개져 원인 구분이 불가능했다. 부트스트랩 실패는 `BootstrapFailureMessage`를 거쳐 스플래시의 `showRetry`로 표면화된다(게스트 전환으로 부트스트랩이 비동기가 되면서 비로소 쓰이는 경로). - `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰은 로그인/가입 파라미터로만 서버에 가고, 갱신 전달은 `SyncPushTokenUseCase`까지 배선됐지만 서버 API 미확정이라 `UnconfirmedPushTokenRepository`(no-op)가 주입돼 있다. 그래서 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐. +- **서버의 "등록된 알람 없음"은 `404` + `responseCode: URT_001`**(2026-09-25 실측: `"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."`). `AlarmRepositoryImpl.refresh()`가 이것만 `AlarmRefreshOutcome.notRegistered`로 매핑하고, 나머지 에러 코드는 그대로 throw한다. **이 셋을 절대 뭉개지 말 것** — `.registered`(병합·재스케줄) / `.notRegistered`(**로컬 기록까지 정리**) / `throw`(**세션을 지킨다** — 네트워크 실패가 세션 소멸이 되면 지하철에서 앱을 여는 것만으로 알람이 사라진다). 판정은 `AlarmSessionReconciler.reconcile(current:server:now:)` 한 곳이고, 거기서 `server: nil`이 "못 물어봤다", `.notRegistered`가 "없다고 답했다"다. - AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료. - **막차 경로(`/routes/last-routes`) 응답 캐시는 의도적으로 미도입**이다. 장소·역지오코딩과 달리 틀렸을 때의 피해가 "막차를 놓친다"라서, 도입하려면 두 가지가 함께 와야 한다: ① 화면에 **"HH:mm 기준" 스탬프 필수**(스탬프 없이 표시 금지), ② **알람 등록 근거로는 캐시 사용 금지** — 지금 `RegisterAlarmUseCase`는 사용자가 고른 `LastRoute`를 그대로 받으므로, 이 규칙을 강제하려면 등록 시점 재조회가 필요해 시그니처가 바뀐다. TTL은 쓰지 않는다(막차의 유효 시간은 벽시계가 아니라 `departureTime` 자체다 — `RouteCardViewData.asPastTrain`이 이미 그 전환을 한다). - Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`. diff --git a/Projects/App/Sources/Adapters/DevChangeSimulator.swift b/Projects/App/Sources/Adapters/DevChangeSimulator.swift index 003f1404..8f4cdd1c 100644 --- a/Projects/App/Sources/Adapters/DevChangeSimulator.swift +++ b/Projects/App/Sources/Adapters/DevChangeSimulator.swift @@ -24,17 +24,19 @@ struct DevChangeSimulatingAlarmRepository: AlarmRepository { try await base.cancel(lastRouteId: lastRouteId) } - func refresh() async throws -> AlarmInfo { + func refresh() async throws -> AlarmRefreshOutcome { // 보류 중 변경 주입이 있으면 서버 대신 시뮬레이터가 응답한다(1회 소비). if let injected = await DevChangeSimulator.shared.consumeInjectedInfo() { print("⚠️ [DEV 변경 시뮬레이터] 변형 AlarmInfo 반환: departure=\(String(describing: injected.departureTime))") - return injected + return .registered(injected) } - let info = try await base.refresh() - await DevChangeSimulator.shared.noteKnownSession( - routeId: info.lastRouteId, departureTime: info.departureTime - ) - return info + let outcome = try await base.refresh() + if case let .registered(info) = outcome { + await DevChangeSimulator.shared.noteKnownSession( + routeId: info.lastRouteId, departureTime: info.departureTime + ) + } + return outcome } } diff --git a/Projects/App/Sources/AlarmSyncService.swift b/Projects/App/Sources/AlarmSyncService.swift index 9b6421e5..22f72579 100644 --- a/Projects/App/Sources/AlarmSyncService.swift +++ b/Projects/App/Sources/AlarmSyncService.swift @@ -43,7 +43,7 @@ final class AlarmSyncService: AlarmSyncRequesting { /// 세션은 Store가 소유하고, 이건 diff 한 번에만 쓰이는 지역 기억이다. private var previousServerInfo: AlarmInfo? /// 진행 중 동기화 — 트리거가 겹치면(예: 앱 시작 직후 포그라운드 노티) 합류한다. - private var inFlight: Task? + private var inFlight: Task? private var foregroundObserver: (any NSObjectProtocol)? // 앱 수명 객체(조합 루트 소유) — 해제 경로가 없어 관찰 해지/태스크 취소 정리가 없다. @@ -120,7 +120,7 @@ final class AlarmSyncService: AlarmSyncRequesting { @discardableResult private func sync() async -> AlarmInfo? { if let inFlight { - return await inFlight.value + return await inFlight.value?.info } Self.logger.info("알람 동기화 시작") // 재실행 브리지 복원 — 세션을 디스크에서 되살린다. Store가 1회만 수행한다. @@ -129,7 +129,10 @@ final class AlarmSyncService: AlarmSyncRequesting { // diff의 "이전 값"은 이번 갱신 **전**의 서버 값이다. previousServerInfo = current?.server - let task = Task { [refreshAlarmUseCase, current] () -> AlarmInfo? in + // nil = 조회 실패(세션 유지). `.notRegistered` = 서버가 없다고 확정(정리). + // 이 구분이 없던 시절엔 서버의 "등록된 알람 없음"(404 URT_001)이 실패로 흘러, + // 세션이 사라져도 앱이 계속 붙들고 매 실행마다 에러 로그가 남았다. + let task = Task { [refreshAlarmUseCase, current] () -> AlarmRefreshOutcome? in do { return try await refreshAlarmUseCase.execute(current: current) } catch { @@ -138,18 +141,19 @@ final class AlarmSyncService: AlarmSyncRequesting { } } inFlight = task - let info = await task.value + let outcome = await task.value inFlight = nil + let info = outcome?.info // 만료·서버 우선·톰스톤 메아리 판정이 전부 여기 한 번에 일어난다. // 이전에는 이 판단이 선행 만료 후보 → refresh → 3분기 서버 우선 → 톰스톤 기록으로 // 흩어져 있었다(그래서 "1차 방어 / 2차 방어" 주석이 붙었다). - let outcome = AlarmSessionReconciler.reconcile( - current: current, server: info, now: now() + let reconciled = AlarmSessionReconciler.reconcile( + current: current, server: outcome, now: now() ) - await sessionStore.apply(outcome) + await sessionStore.apply(reconciled) - switch outcome { + switch reconciled { case let .refreshed(session): Self.logger.info( "알람 동기화 성공: route=\(session.server.lastRouteId, privacy: .public)" diff --git a/Projects/App/Tests/AlarmSyncServiceTests.swift b/Projects/App/Tests/AlarmSyncServiceTests.swift index 459227b9..36babc99 100644 --- a/Projects/App/Tests/AlarmSyncServiceTests.swift +++ b/Projects/App/Tests/AlarmSyncServiceTests.swift @@ -33,22 +33,33 @@ private nonisolated final class ValueBox: @unchecked Sendable { } } +/// 큐는 `AlarmInfo` 그대로 받는다 — 기존 케이스 대부분이 "서버에 세션이 있다"라서 +/// 매 호출처에 `.registered(...)`를 쓰게 하면 읽기만 나빠진다. 서버가 "없다"고 확정한 +/// 경우는 `enqueueNotRegistered()`로 명시한다(실패 = `.failure`와 구분되어야 한다). private final class RefreshStub: RefreshAlarmUseCase, @unchecked Sendable { private let lock = NSLock() - private var queue: [Result] - init(_ results: [Result]) { queue = results } + private var queue: [Result] + init(_ results: [Result]) { + queue = results.map { $0.map { AlarmRefreshOutcome.registered($0) } } + } func enqueue(_ result: Result) { lock.lock() - queue.append(result) + queue.append(result.map { AlarmRefreshOutcome.registered($0) }) + lock.unlock() + } + /// 서버가 "등록된 알람 없음"(URT_001)을 확정한 응답. + func enqueueNotRegistered() { + lock.lock() + queue.append(.success(.notRegistered)) lock.unlock() } // NSLock은 async 컨텍스트에서 직접 못 쓴다 — 동기 헬퍼로 분리. - private func dequeue() -> Result? { + private func dequeue() -> Result? { lock.lock() defer { lock.unlock() } return queue.isEmpty ? nil : queue.removeFirst() } - func execute(current: AlarmSession?) async throws -> AlarmInfo { + func execute(current: AlarmSession?) async throws -> AlarmRefreshOutcome { guard let next = dequeue() else { throw StubError() } return try next.get() } @@ -201,6 +212,42 @@ private func makeHarness( struct AlarmSyncServiceTests { // MARK: - 신선도 스탬프 (Phase 16) + // MARK: - 서버가 "등록된 알람 없음"을 확정 (404 URT_001) + + /// 서버가 세션 없음을 확정하면 로컬 기록도 정리된다. 이전에는 이 응답이 throw로 + /// 흘러 **조회 실패와 같은 취급**을 받아, 서버에서 세션이 사라져도 앱은 낡은 세션을 + /// 계속 붙들었다 — 사용자는 울리지 않을 알람을 믿게 된다. + @Test + func serverSaysNotRegistered_clearsLocalSession() async { + let harness = makeHarness(seeded: AlarmSession( + server: info(route: "r1", departure: fixedNow.addingTimeInterval(3600)), + local: .empty, + syncedAt: fixedNow.addingTimeInterval(-600) + )) + harness.refresh.enqueueNotRegistered() + + await harness.sut.syncNow() + + #expect(harness.store.current == nil) + } + + /// 같은 "세션 없음" 결과라도 **조회 실패는 세션을 지킨다.** 둘이 구분되지 않으면 + /// 지하철에서 앱을 여는 것만으로 알람이 사라진다. + @Test + func syncFailure_keepsLocalSession() async { + let seeded = AlarmSession( + server: info(route: "r1", departure: fixedNow.addingTimeInterval(3600)), + local: .empty, + syncedAt: fixedNow.addingTimeInterval(-600) + ) + let harness = makeHarness(seeded: seeded) + harness.refresh.enqueue(.failure(StubError())) + + await harness.sut.syncNow() + + #expect(harness.store.current?.server.lastRouteId == "r1") + } + @Test func syncSuccess_publishesCheckedAtAndPersistsSyncedAt() async { let harness = makeHarness() diff --git a/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift b/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift index b6032100..555725c8 100644 --- a/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift +++ b/Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift @@ -20,11 +20,24 @@ public struct AlarmRepositoryImpl: AlarmRepository { ) } - 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()) + /// 서버는 "등록된 알람 없음"을 **404 + `URT_001`** 로 알린다(2026-09-25 실측: + /// `{"responseCode":"URT_001","message":"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."}`). + /// 이걸 throw로 흘리면 조회 실패와 구분되지 않아, 서버가 세션을 잃어도 앱이 낡은 + /// 세션을 계속 붙들고 평상시엔 매 실행마다 에러 로그가 남는다. + public func refresh() async throws -> AlarmRefreshOutcome { + do { + let dto: AlarmRefreshResponseDTO = try await networkClient.requestEnveloped( + AlarmEndpoint.refresh + ) + guard let info = dto.toEntity() else { + throw NetworkError.decoding(underlying: MissingResultError()) + } + return .registered(info) + } catch let error as ServerError where error.code == Self.notRegisteredCode { + return .notRegistered } - return info } + + /// 서버가 "이 유저에게 등록된 경로가 없다"를 나타내는 responseCode. + private static let notRegisteredCode = "URT_001" } diff --git a/Projects/Data/Tests/AlarmRepositoryImplTests.swift b/Projects/Data/Tests/AlarmRepositoryImplTests.swift new file mode 100644 index 00000000..838e1d18 --- /dev/null +++ b/Projects/Data/Tests/AlarmRepositoryImplTests.swift @@ -0,0 +1,61 @@ +@testable import AtchaData +import CoreNetwork +import Domain +import Foundation +import Testing + +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()) + } +} + +/// 2026-09-25 실측으로 확인한 서버 응답을 그대로 고정한다. +struct AlarmRepositoryImplTests { + /// 서버가 "등록된 알람 없음"을 알리는 실제 응답: + /// `404 {"responseCode":"URT_001","message":"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."}` + /// + /// 이걸 throw로 흘리면 조회 실패와 구분되지 않아, 상류(Reconciler)가 "못 물어봤다"로 + /// 해석해 세션을 계속 붙든다. + @Test + func refresh_urt001_isNotRegisteredRatherThanFailure() async throws { + let body = Data(#"{"responseCode":"URT_001","message":"id(1010) 유저가 등록한 경로를 찾을 수 없습니다."}"#.utf8) + let sut = AlarmRepositoryImpl( + networkClient: StubNetworkClient(result: .failure(.unacceptableStatus(code: 404, data: body))) + ) + + #expect(try await sut.refresh() == .notRegistered) + } + + @Test + func refresh_success_returnsRegisteredInfo() async throws { + let body = Data(#"{"responseCode":"SUCCESS","result":{"lastRouteId":"route-1","isReal":"true"}}"#.utf8) + let sut = AlarmRepositoryImpl( + networkClient: StubNetworkClient(result: .success(body)) + ) + + #expect(try await sut.refresh().info?.lastRouteId == "route-1") + } + + /// 다른 에러 코드는 **여전히 throw여야 한다** — URT_001만 정상 상태다. + @Test + func refresh_otherServerError_stillThrows() async { + let body = Data(#"{"responseCode":"REQ_002","message":"Authorization 헤더가 없습니다"}"#.utf8) + let sut = AlarmRepositoryImpl( + networkClient: StubNetworkClient(result: .failure(.unacceptableStatus(code: 401, data: body))) + ) + + await #expect(throws: ServerError.self) { + _ = try await sut.refresh() + } + } +} diff --git a/Projects/Domain/Sources/AlarmSessionReconciler.swift b/Projects/Domain/Sources/AlarmSessionReconciler.swift index 54a98c0e..67699af3 100644 --- a/Projects/Domain/Sources/AlarmSessionReconciler.swift +++ b/Projects/Domain/Sources/AlarmSessionReconciler.swift @@ -24,21 +24,23 @@ public enum AlarmSessionReconciler { /// - Parameters: /// - current: 로컬이 아는 세션(없으면 nil). - /// - server: refresh 결과. 실패·오프라인이면 nil을 넘긴다 — - /// 그때도 로컬 만료 판정은 돌아야 한다. + /// - server: refresh 결과. **조회를 못 했으면(실패·오프라인) nil**을 넘긴다 — + /// 그때도 로컬 만료 판정은 돌아야 한다. 서버가 "없다"고 **답한** 것은 + /// nil이 아니라 `.notRegistered`다. 둘을 같은 값으로 넘기면 네트워크 + /// 실패가 세션을 지워 버린다. /// - now: 주입된 현재 시각(실 `Date()` 의존 금지 규약). public static func reconcile( current: AlarmSession?, - server: AlarmInfo?, + server: AlarmRefreshOutcome?, now: Date ) -> Outcome { switch (current, server) { - case (nil, nil): + case (nil, nil), (nil, .notRegistered): return .ended // 로컬에 기록이 없고 서버에 세션이 있다 — 재실행 후 발견이거나 첫 sync다. // 로컬 사실을 모르므로 `.empty`로 시작하고, 다음 등록이 채운다. - case let (nil, .some(info)): + case let (nil, .registered(info)): let discovered = AlarmSession(server: info, local: .empty, syncedAt: now) return expiryOutcome(for: discovered, now: now) ?? .refreshed(discovered) @@ -48,7 +50,13 @@ public enum AlarmSessionReconciler { if session.isEnded { return .ignoredStaleEcho } return expiryOutcome(for: session, now: now) ?? .refreshed(session) - case let (.some(session), .some(info)): + // 서버가 **없다고 확정했다**(URT_001). 로컬 기록까지 정리한다 — 다른 기기나 + // 서버 쪽 정리로 세션이 사라졌는데 이 기기만 붙들고 있으면, 울리지 않을 알람을 + // 믿고 자게 된다. 이미 끝난 세션이면 지울 것도 없다. + case let (.some(session), .notRegistered): + return session.isEnded ? .ignoredStaleEcho : .ended + + case let (.some(session), .registered(info)): return reconcile(session: session, server: info, now: now) } } diff --git a/Projects/Domain/Sources/Entities/AlarmRefreshOutcome.swift b/Projects/Domain/Sources/Entities/AlarmRefreshOutcome.swift new file mode 100644 index 00000000..c5bb9863 --- /dev/null +++ b/Projects/Domain/Sources/Entities/AlarmRefreshOutcome.swift @@ -0,0 +1,22 @@ +/// `refresh()`가 돌려줄 수 있는 답. **`AlarmInfo?` 하나로 뭉개면 안 된다** — +/// "서버가 없다고 답했다"와 "못 물어봤다"가 구분되지 않기 때문이다. +/// +/// | 답 | 의미 | 세션 처리 | +/// |---|---|---| +/// | `.registered` | 서버에 세션이 있다 | 병합·재스케줄 | +/// | `.notRegistered` | **서버가 없다고 확정했다** | 로컬 기록까지 정리 | +/// | `throw` | 못 물어봤다(실패·오프라인) | **지킨다** — 네트워크 실패가 세션 소멸이 되면 안 된다 | +/// +/// 가운데 칸이 이 타입이 생긴 이유다. 서버는 이 상태를 404 + `URT_001`로 알리는데, +/// 그게 throw로 흘러 "실패"와 같은 취급을 받고 있었다 — 서버가 세션을 잃어도 앱은 +/// 낡은 세션을 계속 붙들고, 등록된 알람이 없는 평상시엔 매 실행마다 에러 로그가 찍혔다. +public enum AlarmRefreshOutcome: Sendable, Equatable { + case registered(AlarmInfo) + case notRegistered + + /// 값이 필요한 호출처용 — 부재와 실패를 구분할 필요가 없는 자리에서만 쓴다. + public var info: AlarmInfo? { + if case let .registered(info) = self { return info } + return nil + } +} diff --git a/Projects/Domain/Sources/Interfaces/AlarmRepository.swift b/Projects/Domain/Sources/Interfaces/AlarmRepository.swift index 2bd099b2..7ea4638f 100644 --- a/Projects/Domain/Sources/Interfaces/AlarmRepository.swift +++ b/Projects/Domain/Sources/Interfaces/AlarmRepository.swift @@ -3,5 +3,6 @@ public protocol AlarmRepository: Sendable { func register(lastRouteId: String) async throws func cancel(lastRouteId: String) async throws - func refresh() async throws -> AlarmInfo + /// throw = 조회 실패(세션을 지킨다). `.notRegistered` = 서버가 없다고 확정(정리한다). + func refresh() async throws -> AlarmRefreshOutcome } diff --git a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift index 145e7641..2ca55d53 100644 --- a/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RefreshAlarmUseCase.swift @@ -3,7 +3,7 @@ import Foundation public protocol RefreshAlarmUseCase: Sendable { /// - Parameter current: 로컬이 아는 세션. 도보 초의 출처이며, 재스케줄 시각 계산에 /// 쓰인다. nil이면 버퍼만 적용된다(등록 경로와 같은 폴백). - func execute(current: AlarmSession?) async throws -> AlarmInfo + func execute(current: AlarmSession?) async throws -> AlarmRefreshOutcome } public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { @@ -21,8 +21,10 @@ public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { self.now = now } - public func execute(current: AlarmSession?) async throws -> AlarmInfo { - let info = try await repository.refresh() + public func execute(current: AlarmSession?) async throws -> AlarmRefreshOutcome { + let outcome = try await repository.refresh() + // 서버가 세션 없음을 확정했으면 재스케줄할 대상 자체가 없다 — 그대로 올린다. + guard case let .registered(info) = outcome else { return outcome } // 시각 변경 시(로컬 알람이 사라진 경우 포함) 재스케줄한다. 기대 발화 시각은 // register와 같은 기준(출발 − 도보 − 버퍼)으로 비교해야 변경이 없어도 매 // refresh마다 재스케줄되는 헛돎이 없다(이중 시각 금지). @@ -32,7 +34,7 @@ public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { // 직접 로드해 routeId를 비교했고, 같은 규칙이 AlarmSyncService에도 있었다. let expected = current?.merging(server: info, syncedAt: nil) ?? AlarmSession(server: info, local: .empty) - guard let expectedFireDate = expected.fireDate else { return info } + guard let expectedFireDate = expected.fireDate else { return outcome } // 새 알람 시각이 이미 과거면 재스케줄하지 않는다 — 과거 fixed 스케줄은 AlarmKit이 // 거부해 refresh 전체를 실패시킬 수 있다. 이 경우의 인지는 갱신 성공 이후 @@ -45,6 +47,6 @@ public struct DefaultRefreshAlarmUseCase: RefreshAlarmUseCase { title: AlarmSchedulingDefaults.title ) } - return info + return outcome } } diff --git a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift index 8a4327c1..a418e36d 100644 --- a/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift +++ b/Projects/Domain/Sources/UseCases/RegisterAlarmUseCase.swift @@ -52,7 +52,7 @@ public struct DefaultRegisterAlarmUseCase: RegisterAlarmUseCase { } // TODO: [미확정 #4] 단일 알람 규약(서버 교체 여부) 확정 전까지 클라이언트가 삭제 후 등록한다. // 기존 알람 확인 실패(= 등록된 알람 없음)와 삭제 실패는 등록을 막지 않는다. - if let existing = try? await repository.refresh() { + if let existing = (try? await repository.refresh())?.info { try? await repository.cancel(lastRouteId: existing.lastRouteId) } try await repository.register(lastRouteId: route.id) diff --git a/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift b/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift index ac55da3e..dbe7556f 100644 --- a/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift +++ b/Projects/Domain/Tests/AlarmSessionReconcilerTests.swift @@ -46,7 +46,7 @@ struct AlarmSessionReconcilerTests { func noCurrent_futureDeparture_refreshes() { let outcome = AlarmSessionReconciler.reconcile( current: nil, - server: info(departureOffset: 600), + server: .registered(info(departureOffset: 600)), now: now ) @@ -65,7 +65,7 @@ struct AlarmSessionReconcilerTests { func noCurrent_pastDeparture_expiresImmediately() { let outcome = AlarmSessionReconciler.reconcile( current: nil, - server: info(departureOffset: -120), + server: .registered(info(departureOffset: -120)), now: now ) @@ -89,7 +89,7 @@ struct AlarmSessionReconcilerTests { func pastDeparture_serverGivesFuture_refreshesNotExpires() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: -120), - server: info(departureOffset: 900), + server: .registered(info(departureOffset: 900)), now: now ) @@ -108,7 +108,7 @@ struct AlarmSessionReconcilerTests { func liveSession_serverReturnsPastDeparture_refreshesForMissedVerdict() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: 3600), // 아직 살아 있다 - server: info(departureOffset: -100), // 서버가 방금 지나갔다고 알림 + server: .registered(info(departureOffset: -100)), // 서버가 방금 지나갔다고 알림 now: now ) @@ -123,7 +123,7 @@ struct AlarmSessionReconcilerTests { func pastDeparture_serverConfirmsSamePast_expires() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: -120), - server: info(departureOffset: -120), + server: .registered(info(departureOffset: -120)), now: now ) @@ -137,7 +137,7 @@ struct AlarmSessionReconcilerTests { func endedSession_serverGivesFuture_revives() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: -600, lifecycle: .ended), - server: info(departureOffset: 900), + server: .registered(info(departureOffset: 900)), now: now ) @@ -152,7 +152,7 @@ struct AlarmSessionReconcilerTests { func endedSession_serverEchoesSamePast_isIgnored() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: -600, lifecycle: .ended), - server: info(departureOffset: -600), + server: .registered(info(departureOffset: -600)), now: now ) @@ -164,7 +164,7 @@ struct AlarmSessionReconcilerTests { func endedSession_differentRoute_acceptsAsNewSession() { let outcome = AlarmSessionReconciler.reconcile( current: session(route: "R1", departureOffset: -600, lifecycle: .ended), - server: info(route: "R2", departureOffset: 900), + server: .registered(info(route: "R2", departureOffset: 900)), now: now ) @@ -182,7 +182,7 @@ struct AlarmSessionReconcilerTests { func sameRoute_preservesLocalFacts() { let outcome = AlarmSessionReconciler.reconcile( current: session(route: "R1", departureOffset: 600, walkSeconds: 300), - server: info(route: "R1", departureOffset: 900), + server: .registered(info(route: "R1", departureOffset: 900)), now: now ) @@ -199,7 +199,7 @@ struct AlarmSessionReconcilerTests { func differentRoute_dropsLocalFacts() { let outcome = AlarmSessionReconciler.reconcile( current: session(route: "R1", departureOffset: 600, walkSeconds: 300), - server: info(route: "R2", departureOffset: 900), + server: .registered(info(route: "R2", departureOffset: 900)), now: now ) @@ -216,7 +216,7 @@ struct AlarmSessionReconcilerTests { func acknowledgedSession_refresh_keepsLifecycle() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: 600, lifecycle: .acknowledged), - server: info(departureOffset: 900), + server: .registered(info(departureOffset: 900)), now: now ) @@ -241,6 +241,56 @@ struct AlarmSessionReconcilerTests { #expect(session.lifecycle == .ended) } + // MARK: - 서버가 "없다"고 확정 (URT_001) + + /// **이 구분이 이 enum의 존재 이유다.** 서버가 세션 없음을 확정하면(404 URT_001) + /// 로컬 기록까지 정리한다 — 다른 기기나 서버 쪽 정리로 세션이 사라졌는데 이 기기만 + /// 붙들고 있으면, 사용자가 울리지 않을 알람을 믿고 자게 된다. + @Test + func serverSaysNotRegistered_withLiveSession_ends() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: 600), + server: .notRegistered, + now: now + ) + + #expect(outcome == .ended) + } + + /// 바로 위와 **같은 세션, 다른 입력**. 조회를 못 한 것(nil)은 세션을 지킨다 — + /// 두 경우를 같은 값으로 넘기면 네트워크 실패가 세션을 지워 버린다. + @Test + func serverUnreachable_withSameLiveSession_keepsIt() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: 600), + server: nil, + now: now + ) + + guard case .refreshed = outcome else { + Issue.record("refreshed 기대, 실제 \(outcome)"); return + } + } + + @Test + func serverSaysNotRegistered_withoutLocalSession_ends() { + #expect( + AlarmSessionReconciler.reconcile(current: nil, server: .notRegistered, now: now) == .ended + ) + } + + /// 이미 끝난 세션이면 지울 것이 없다 — 톰스톤을 건드리지 않는다. + @Test + func serverSaysNotRegistered_withTombstone_isIgnored() { + let outcome = AlarmSessionReconciler.reconcile( + current: session(departureOffset: -600, lifecycle: .ended), + server: .notRegistered, + now: now + ) + + #expect(outcome == .ignoredStaleEcho) + } + // MARK: - 서버 응답 없음 (오프라인·실패) /// 네트워크 실패가 "세션 소멸"이 되면 안 된다 — 미래 세션은 그대로 유지한다. @@ -277,7 +327,7 @@ struct AlarmSessionReconcilerTests { func serverDropsDepartureTime_isEnded() { let outcome = AlarmSessionReconciler.reconcile( current: session(departureOffset: 600), - server: info(departureOffset: nil), + server: .registered(info(departureOffset: nil)), now: now ) diff --git a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift index d832558f..eee90d1a 100644 --- a/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultCancelAlarmUseCaseTests.swift @@ -22,7 +22,7 @@ private struct SpyAlarmRepository: AlarmRepository { if let cancelError { throw cancelError } } - func refresh() async throws -> AlarmInfo { + func refresh() async throws -> AlarmRefreshOutcome { await log.append("refresh") throw StubError() } diff --git a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift index c852e5b3..437bf763 100644 --- a/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRefreshAlarmUseCaseTests.swift @@ -12,6 +12,8 @@ private struct StubError: Error {} private struct StubAlarmRepository: AlarmRepository { let log: CallLog var refreshResult: Result + /// 서버가 "등록된 알람 없음"을 확정한 경우(URT_001) — 설정되면 refreshResult보다 우선한다. + var notRegistered = false func register(lastRouteId: String) async throws { await log.append("register:\(lastRouteId)") @@ -21,9 +23,10 @@ private struct StubAlarmRepository: AlarmRepository { await log.append("cancel:\(lastRouteId)") } - func refresh() async throws -> AlarmInfo { + func refresh() async throws -> AlarmRefreshOutcome { await log.append("refresh") - return try refreshResult.get() + if notRegistered { return .notRegistered } + return .registered(try refreshResult.get()) } } @@ -78,8 +81,8 @@ struct DefaultRefreshAlarmUseCaseTests { scheduler: SpyAlarmScheduler(log: log, scheduledDate: Date(timeIntervalSince1970: 1_000)), now: fixedNow ) - let info = try await sut.execute(current: nil) - #expect(info.departureTime == newDeparture) + let outcome = try await sut.execute(current: nil) + #expect(outcome.info?.departureTime == newDeparture) // 스케줄 시각은 버퍼 반영값: 2000 − 180 = 1820 #expect(await log.events == ["refresh", "scheduledFireDate", "replaceAlarm:route-1@1820"]) } @@ -215,4 +218,26 @@ struct DefaultRefreshAlarmUseCaseTests { } #expect(await log.events == ["refresh"]) } + + /// 서버가 "등록된 알람 없음"(URT_001)을 확정하면 **재스케줄 대상이 없다** — + /// 스케줄러를 건드리지 않고 그대로 올려 보낸다. 이전에는 이 응답이 throw로 흘러 + /// 조회 실패와 구분되지 않았다. + @Test + func execute_serverSaysNotRegistered_passesThroughWithoutTouchingScheduler() async throws { + let log = CallLog() + let sut = DefaultRefreshAlarmUseCase( + repository: StubAlarmRepository( + log: log, + refreshResult: .success(makeInfo(departureTime: Date(timeIntervalSince1970: 9_000))), + notRegistered: true + ), + scheduler: SpyAlarmScheduler(log: log, scheduledDate: nil), + now: fixedNow + ) + + let outcome = try await sut.execute(current: nil) + + #expect(outcome == .notRegistered) + #expect(await log.events == ["refresh"]) + } } diff --git a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift index ce50b005..5abe4de9 100644 --- a/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift +++ b/Projects/Domain/Tests/DefaultRegisterAlarmUseCaseTests.swift @@ -23,10 +23,10 @@ private struct SpyAlarmRepository: AlarmRepository { await log.append("cancel:\(lastRouteId)") } - func refresh() async throws -> AlarmInfo { + func refresh() async throws -> AlarmRefreshOutcome { await log.append("refresh") guard let existing else { throw StubError() } - return existing + return .registered(existing) } } diff --git a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift index f391f5a0..fe1efad9 100644 --- a/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift +++ b/Projects/Domain/Tests/LastTrainActivityLifecycleTests.swift @@ -32,7 +32,7 @@ private struct SpyAlarmRepository: AlarmRepository { await log.append("cancel:\(lastRouteId)") } - func refresh() async throws -> AlarmInfo { + func refresh() async throws -> AlarmRefreshOutcome { await log.append("refresh") throw StubError() }