diff --git a/Projects/App/Sources/DI/Dependencies.swift b/Projects/App/Sources/DI/Dependencies.swift index 0f36c3b..493c737 100644 --- a/Projects/App/Sources/DI/Dependencies.swift +++ b/Projects/App/Sources/DI/Dependencies.swift @@ -1,3 +1,5 @@ +import Data +import Domain import ThirdParty enum Dependencies { @@ -5,8 +7,10 @@ enum Dependencies { _ values: inout DependencyValues, infra: InfraContainer ) { - // TODO: Domain Client factory 등록 - _ = infra - _ = values + values.authClient = .live( + baseURL: infra.configuration.baseURL, + keychain: infra.keychain, + oauthServices: OAuthServiceFactory.makeStub() + ) } } diff --git a/Projects/App/Sources/DI/InfraContainer.swift b/Projects/App/Sources/DI/InfraContainer.swift index eb49a30..b180f08 100644 --- a/Projects/App/Sources/DI/InfraContainer.swift +++ b/Projects/App/Sources/DI/InfraContainer.swift @@ -1,6 +1,7 @@ import CoreStorage import Foundation +/// App 인프라 컨테이너. 설정과 로컬 저장소만 보유한다. struct InfraContainer: Sendable { let configuration: AppConfiguration let userDefaults: any UserDefaultsStorage diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift index f7914ff..4be2406 100644 --- a/Projects/Data/Project.swift +++ b/Projects/Data/Project.swift @@ -9,5 +9,11 @@ let project = ProjectFactory.framework( .coreStorage, .sharedLogger, .sharedUtils, + ], + includesTests: true, + testsDependencies: [ + .domain, + .coreNetwork, + .coreStorage, ] ) diff --git a/Projects/Data/README.md b/Projects/Data/README.md index 9a64b93..5b7599c 100644 --- a/Projects/Data/README.md +++ b/Projects/Data/README.md @@ -1,31 +1,56 @@ # Data ## 책임 -- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory` +- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, live 조립 ## 현재 상태 -- placeholder 골격 +- Auth 구현 추가 + - DTO/Endpoint/Datasource + - `AuthLocalDatasource` + - `AuthTokenRefresher` + - `AuthRepositoryImpl` + - `AuthClientFactory` (repository → Domain client adapter) + - `AuthClient.live` (plain/refresher/authed 풀 조립) + - `OAuthService` / `OAuthServiceFactory` (PR1 stub) ## 이후 패턴 -- `Data//{DTO,Datasource,Repository}` +- `Data//{Client,DTO,Datasource,Repository,Service}` - `*RepositoryImpl` -- `*ClientFactory` +- `*ClientFactory` + `*Client.live` ## 의존 - 허용: Domain, Core/*, SharedLogger, SharedUtils - 금지: Feature ## 내부 규칙 -- Domain `*Client` 의 live 구현을 factory 로 제공 -- App 조립 지점에서 factory 등록 +- Domain `*Client` 의 live 구현(`*Client.live`)은 Data 에서 제공 +- App 은 pure infra 준비 + `prepareDependencies` 등록만 담당 - Feature 가 Data 를 직접 import 하지 않음 +- refresh 는 plain client + `AuthTokenRefresher` 경로 +- refresh 실패 시 unauthorized 만 로컬 세션 삭제, badRequest/일시 네트워크 오류는 세션 유지 +- request body encode 는 Data remote 에서 수행하고 실패 시 throw +- OAuth credential 은 `OAuthService` 가 담당 (Souzip 스타일) ## 주요 진입점 -- (현재) `Sources/Placeholder.swift` -- (이후) `*ClientFactory` +- `Sources/Auth/Client/AuthClient+Live.swift` +- `Sources/Auth/Client/AuthClientFactory.swift` +- `Sources/Auth/Repository/AuthRepositoryImpl.swift` +- `Sources/Auth/Datasource/AuthRemoteDatasource.swift` +- `Sources/Auth/Datasource/AuthLocalDatasource.swift` +- `Sources/Auth/Service/AuthTokenRefresher.swift` +- `Sources/Auth/Service/OAuth/OAuthService.swift` +- `Sources/Auth/Service/OAuth/OAuthServiceFactory.swift` +- `Sources/Auth/Endpoint/AuthEndpoint.swift` +- `Sources/Auth/DTO/*` ## 테스트 포인트 -- repository/factory 매핑 (구현 시) +- DTO 매핑 +- local datasource 저장/삭제 +- repository login/restore/logout +- token refresher rotation 교체 저장 +- logout 시 로컬 세션 삭제 +- factory credential → repository 연결 +- OAuth stub notConfigured ## 관련 문서 - [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) diff --git a/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift b/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift new file mode 100644 index 0000000..177fd02 --- /dev/null +++ b/Projects/Data/Sources/Auth/Client/AuthClient+Live.swift @@ -0,0 +1,49 @@ +import CoreNetwork +import CoreStorage +import Domain +import Foundation + +public extension AuthClient { + /// Auth live 조립. plain refresh 경로로 refresher를 먼저 만들어 순환 의존을 끊는다. + static func live( + baseURL: URL, + keychain: any KeychainStorage, + oauthServices: OAuthServices + ) -> AuthClient { + let networkConfiguration = NetworkConfiguration(baseURL: baseURL) + let plainNetworkClient = DefaultNetworkClient.plain( + configuration: networkConfiguration + ) + let local = AuthLocalDatasource(keychain: keychain) + + // refresh는 plain only. logout 경로가 없어 authed 자리에 plain을 넣는다. + let refreshRemote = AuthRemoteDatasource( + plainClient: plainNetworkClient, + authedClient: plainNetworkClient + ) + let tokenRefresher = AuthTokenRefresher( + remote: refreshRemote, + local: local + ) + let authedNetworkClient = DefaultNetworkClient.authed( + configuration: networkConfiguration, + tokenProvider: local, + tokenRefresher: tokenRefresher + ) + let fullRemote = AuthRemoteDatasource( + plainClient: plainNetworkClient, + authedClient: authedNetworkClient + ) + let repository = AuthRepositoryImpl( + remote: fullRemote, + local: local + ) + + return AuthClientFactory.make( + repository: repository, + credentialProvider: { provider in + try await oauthServices.service(for: provider).login() + } + ) + } +} diff --git a/Projects/Data/Sources/Auth/Client/AuthClientFactory.swift b/Projects/Data/Sources/Auth/Client/AuthClientFactory.swift new file mode 100644 index 0000000..47f35d9 --- /dev/null +++ b/Projects/Data/Sources/Auth/Client/AuthClientFactory.swift @@ -0,0 +1,30 @@ +import Domain +import Foundation + +public enum AuthClientFactory { + public static func make( + repository: AuthRepositoryImpl, + credentialProvider: @escaping @Sendable (AuthProvider) async throws -> String + ) -> AuthClient { + AuthClient( + restoreSession: { + try await repository.restoreSession() + }, + login: { provider in + let credential = try await credentialProvider(provider) + switch provider { + case .kakao: + return try await repository.loginWithKakao(accessToken: credential) + case .apple: + return try await repository.loginWithApple(identityToken: credential) + } + }, + logout: { + try await repository.logout() + }, + currentSession: { + await repository.currentSession() + } + ) + } +} diff --git a/Projects/Data/Sources/Auth/DTO/AppleLoginRequestDTO.swift b/Projects/Data/Sources/Auth/DTO/AppleLoginRequestDTO.swift new file mode 100644 index 0000000..00eb307 --- /dev/null +++ b/Projects/Data/Sources/Auth/DTO/AppleLoginRequestDTO.swift @@ -0,0 +1,5 @@ +import Foundation + +struct AppleLoginRequestDTO: Encodable, Sendable { + let identityToken: String +} diff --git a/Projects/Data/Sources/Auth/DTO/DevLoginRequestDTO.swift b/Projects/Data/Sources/Auth/DTO/DevLoginRequestDTO.swift new file mode 100644 index 0000000..bd97ea7 --- /dev/null +++ b/Projects/Data/Sources/Auth/DTO/DevLoginRequestDTO.swift @@ -0,0 +1,6 @@ +import Foundation + +struct DevLoginRequestDTO: Encodable, Sendable { + // 서버 계약이 빈 body 또는 고정 payload 면 그에 맞춤. + // OpenAPI 확인 불가 시 빈 object `{}` 로 보낸다. +} diff --git a/Projects/Data/Sources/Auth/DTO/KakaoLoginRequestDTO.swift b/Projects/Data/Sources/Auth/DTO/KakaoLoginRequestDTO.swift new file mode 100644 index 0000000..dee4b2f --- /dev/null +++ b/Projects/Data/Sources/Auth/DTO/KakaoLoginRequestDTO.swift @@ -0,0 +1,5 @@ +import Foundation + +struct KakaoLoginRequestDTO: Encodable, Sendable { + let accessToken: String +} diff --git a/Projects/Data/Sources/Auth/DTO/LoginResponseDTO.swift b/Projects/Data/Sources/Auth/DTO/LoginResponseDTO.swift new file mode 100644 index 0000000..bbf2f24 --- /dev/null +++ b/Projects/Data/Sources/Auth/DTO/LoginResponseDTO.swift @@ -0,0 +1,30 @@ +import Domain +import Foundation + +public struct LoginResponseDTO: Decodable, Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + public let isNewUser: Bool + public let profileCompleted: Bool + + public init( + accessToken: String, + refreshToken: String, + isNewUser: Bool, + profileCompleted: Bool + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.isNewUser = isNewUser + self.profileCompleted = profileCompleted + } + + public func toDomain() -> AuthSession { + AuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + isNewUser: isNewUser, + profileCompleted: profileCompleted + ) + } +} diff --git a/Projects/Data/Sources/Auth/DTO/RefreshRequestDTO.swift b/Projects/Data/Sources/Auth/DTO/RefreshRequestDTO.swift new file mode 100644 index 0000000..58284a6 --- /dev/null +++ b/Projects/Data/Sources/Auth/DTO/RefreshRequestDTO.swift @@ -0,0 +1,5 @@ +import Foundation + +struct RefreshRequestDTO: Encodable, Sendable { + let refreshToken: String +} diff --git a/Projects/Data/Sources/Auth/DTO/TokenResponseDTO.swift b/Projects/Data/Sources/Auth/DTO/TokenResponseDTO.swift new file mode 100644 index 0000000..4fecd39 --- /dev/null +++ b/Projects/Data/Sources/Auth/DTO/TokenResponseDTO.swift @@ -0,0 +1,21 @@ +import Domain +import Foundation + +public struct TokenResponseDTO: Decodable, Equatable, Sendable { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + public func applying(to session: AuthSession) -> AuthSession { + AuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + isNewUser: session.isNewUser, + profileCompleted: session.profileCompleted + ) + } +} diff --git a/Projects/Data/Sources/Auth/Datasource/AuthLocalDatasource.swift b/Projects/Data/Sources/Auth/Datasource/AuthLocalDatasource.swift new file mode 100644 index 0000000..c710dd6 --- /dev/null +++ b/Projects/Data/Sources/Auth/Datasource/AuthLocalDatasource.swift @@ -0,0 +1,30 @@ +import CoreNetwork +import CoreStorage +import Domain +import Foundation + +public struct AuthLocalDatasource: TokenProviding { + private let keychain: any KeychainStorage + private let sessionKey: String + + public init(keychain: any KeychainStorage, sessionKey: String = "auth.session") { + self.keychain = keychain + self.sessionKey = sessionKey + } + + public func save(_ session: AuthSession) async throws { + try await keychain.save(session, forKey: sessionKey) + } + + public func load() async throws -> AuthSession? { + try await keychain.get(forKey: sessionKey) + } + + public func clear() async throws { + try await keychain.delete(forKey: sessionKey) + } + + public func accessToken() async throws -> String? { + try await load()?.accessToken + } +} diff --git a/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift b/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift new file mode 100644 index 0000000..42a4e52 --- /dev/null +++ b/Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift @@ -0,0 +1,43 @@ +import CoreNetwork +import Foundation + +public struct AuthRemoteDatasource: Sendable { + private let plainClient: any NetworkClient + private let authedClient: any NetworkClient + + public init( + plainClient: any NetworkClient, + authedClient: any NetworkClient + ) { + self.plainClient = plainClient + self.authedClient = authedClient + } + + public func loginWithKakao(accessToken: String) async throws -> LoginResponseDTO { + let body = try makeEncoder().encode(KakaoLoginRequestDTO(accessToken: accessToken)) + return try await plainClient.request(AuthEndpoint.loginKakao(body)) + } + + public func loginWithApple(identityToken: String) async throws -> LoginResponseDTO { + let body = try makeEncoder().encode(AppleLoginRequestDTO(identityToken: identityToken)) + return try await plainClient.request(AuthEndpoint.loginApple(body)) + } + + public func loginWithDev() async throws -> LoginResponseDTO { + let body = try makeEncoder().encode(DevLoginRequestDTO()) + return try await plainClient.request(AuthEndpoint.loginDev(body)) + } + + public func refresh(refreshToken: String) async throws -> TokenResponseDTO { + let body = try makeEncoder().encode(RefreshRequestDTO(refreshToken: refreshToken)) + return try await plainClient.request(AuthEndpoint.refresh(body)) + } + + public func logout() async throws { + try await authedClient.request(AuthEndpoint.logout) + } + + private func makeEncoder() -> JSONEncoder { + NetworkJSONCoding.makeEncoder() + } +} diff --git a/Projects/Data/Sources/Auth/Endpoint/AuthEndpoint.swift b/Projects/Data/Sources/Auth/Endpoint/AuthEndpoint.swift new file mode 100644 index 0000000..6c661f1 --- /dev/null +++ b/Projects/Data/Sources/Auth/Endpoint/AuthEndpoint.swift @@ -0,0 +1,41 @@ +import CoreNetwork +import Foundation + +enum AuthEndpoint: APIEndpoint { + case loginKakao(Data) + case loginApple(Data) + case loginDev(Data) + case refresh(Data) + case logout + + var path: String { + switch self { + case .loginKakao: + return "/api/auth/login/kakao" + case .loginApple: + return "/api/auth/login/apple" + case .loginDev: + return "/api/auth/login/dev" + case .refresh: + return "/api/auth/refresh" + case .logout: + return "/api/auth/logout" + } + } + + var method: HTTPMethod { + .post + } + + var body: Data? { + switch self { + case let .loginKakao(body), + let .loginApple(body), + let .loginDev(body), + let .refresh(body): + return body + case .logout: + return nil + } + } +} diff --git a/Projects/Data/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Sources/Auth/Repository/AuthRepositoryImpl.swift new file mode 100644 index 0000000..df61fda --- /dev/null +++ b/Projects/Data/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -0,0 +1,107 @@ +import CoreNetwork +import CoreStorage +import Domain +import Foundation + +public struct AuthRepositoryImpl: Sendable { + private let remote: AuthRemoteDatasource + private let local: AuthLocalDatasource + + public init(remote: AuthRemoteDatasource, local: AuthLocalDatasource) { + self.remote = remote + self.local = local + } + + public func restoreSession() async throws -> AuthSession? { + do { + return try await local.load() + } catch { + throw mapStorageError(error) + } + } + + public func currentSession() async -> AuthSession? { + try? await local.load() + } + + public func loginWithKakao(accessToken: String) async throws -> AuthSession { + do { + let dto = try await remote.loginWithKakao(accessToken: accessToken) + return try await saveLoginSession(dto) + } catch { + throw mapLoginError(error) + } + } + + public func loginWithApple(identityToken: String) async throws -> AuthSession { + do { + let dto = try await remote.loginWithApple(identityToken: identityToken) + return try await saveLoginSession(dto) + } catch { + throw mapLoginError(error) + } + } + + public func loginWithDev() async throws -> AuthSession { + do { + let dto = try await remote.loginWithDev() + return try await saveLoginSession(dto) + } catch { + throw mapLoginError(error) + } + } + + public func logout() async throws { + do { + try await remote.logout() + } catch { + // 원격 실패와 무관하게 로컬 세션 삭제 + } + + do { + try await local.clear() + } catch { + throw mapStorageError(error) + } + } + + private func saveLoginSession(_ dto: LoginResponseDTO) async throws -> AuthSession { + let session = dto.toDomain() + do { + try await local.save(session) + return session + } catch { + throw mapStorageError(error) + } + } + + private func mapLoginError(_ error: Error) -> AuthError { + if let authError = error as? AuthError { + return authError + } + + if error is KeychainError { + return mapStorageError(error) + } + + guard let networkError = error as? NetworkError else { + return .unknown(message: String(describing: error)) + } + + switch networkError { + case .transport: + return .network + case .unauthorized, .badRequest: + return .loginFailed + default: + return .unknown(message: String(describing: networkError)) + } + } + + private func mapStorageError(_ error: Error) -> AuthError { + if let authError = error as? AuthError { + return authError + } + return .storage(message: String(describing: error)) + } +} diff --git a/Projects/Data/Sources/Auth/Service/AuthTokenRefresher.swift b/Projects/Data/Sources/Auth/Service/AuthTokenRefresher.swift new file mode 100644 index 0000000..d69dd9a --- /dev/null +++ b/Projects/Data/Sources/Auth/Service/AuthTokenRefresher.swift @@ -0,0 +1,65 @@ +import CoreNetwork +import CoreStorage +import Domain +import Foundation + +public actor AuthTokenRefresher: TokenRefreshing { + private let remote: AuthRemoteDatasource + private let local: AuthLocalDatasource + + public init(remote: AuthRemoteDatasource, local: AuthLocalDatasource) { + self.remote = remote + self.local = local + } + + public func refresh() async throws { + let session: AuthSession + do { + guard let loaded = try await local.load() else { + throw AuthError.unauthorized + } + session = loaded + } catch let error as AuthError { + throw error + } catch { + throw AuthError.storage(message: String(describing: error)) + } + + do { + let tokens = try await remote.refresh(refreshToken: session.refreshToken) + do { + try await local.save(tokens.applying(to: session)) + } catch { + throw AuthError.storage(message: String(describing: error)) + } + } catch let error as AuthError { + throw error + } catch { + throw await mapRefreshFailure(error) + } + } + + private func mapRefreshFailure(_ error: Error) async -> AuthError { + guard let networkError = error as? NetworkError else { + // 알 수 없는 오류는 세션을 유지한다. + return .unknown(message: String(describing: error)) + } + + switch networkError { + case .unauthorized: + // invalid/expired refresh 로 보고 세션 종료 + // clear 실패는 삼키고 unauthorized UX를 유지한다. + try? await local.clear() + return .unauthorized + case .transport: + // 일시 네트워크 오류는 세션 유지 + return .network + case .badRequest: + // 형식/검증 오류는 세션 유지 + return .unknown(message: String(describing: networkError)) + default: + // 서버/기타 오류는 세션 유지 + return .unknown(message: String(describing: networkError)) + } + } +} diff --git a/Projects/Data/Sources/Auth/Service/OAuth/OAuthService.swift b/Projects/Data/Sources/Auth/Service/OAuth/OAuthService.swift new file mode 100644 index 0000000..5d9b1d3 --- /dev/null +++ b/Projects/Data/Sources/Auth/Service/OAuth/OAuthService.swift @@ -0,0 +1,6 @@ +import Domain +import Foundation + +public protocol OAuthService: Sendable { + func login() async throws -> String +} diff --git a/Projects/Data/Sources/Auth/Service/OAuth/OAuthServiceFactory.swift b/Projects/Data/Sources/Auth/Service/OAuth/OAuthServiceFactory.swift new file mode 100644 index 0000000..d5d92b7 --- /dev/null +++ b/Projects/Data/Sources/Auth/Service/OAuth/OAuthServiceFactory.swift @@ -0,0 +1,48 @@ +import Domain +import Foundation + +public struct OAuthServices: Sendable { + public let kakao: any OAuthService + public let apple: any OAuthService + + public init(kakao: any OAuthService, apple: any OAuthService) { + self.kakao = kakao + self.apple = apple + } + + public func service(for provider: AuthProvider) -> any OAuthService { + switch provider { + case .kakao: + return kakao + case .apple: + return apple + } + } +} + +public enum OAuthServiceFactory { + /// PR1 stub. 실제 SDK 연동은 후속 구현으로 교체한다. + public static func makeStub() -> OAuthServices { + OAuthServices( + kakao: NotConfiguredOAuthService(provider: .kakao), + apple: NotConfiguredOAuthService(provider: .apple) + ) + } +} + +struct NotConfiguredOAuthService: OAuthService { + let provider: AuthProvider + + func login() async throws -> String { + switch provider { + case .kakao: + throw AuthError.notConfigured( + message: "Kakao login is not configured yet" + ) + case .apple: + throw AuthError.notConfigured( + message: "Apple login is not configured yet" + ) + } + } +} diff --git a/Projects/Data/Tests/Auth/AuthClientFactoryTests.swift b/Projects/Data/Tests/Auth/AuthClientFactoryTests.swift new file mode 100644 index 0000000..712e21c --- /dev/null +++ b/Projects/Data/Tests/Auth/AuthClientFactoryTests.swift @@ -0,0 +1,167 @@ +@testable import Data +import CoreNetwork +import Domain +import Foundation +import XCTest + +final class AuthClientFactoryTests: XCTestCase { + override func setUp() async throws { + AuthURLProtocolStub.reset() + } + + override func tearDown() async throws { + AuthURLProtocolStub.reset() + } + + func test_factory가_restore_logout_currentSession을_repository에_연결() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { request in + if request.url?.path == "/api/auth/logout" { + return .init(statusCode: 200, headers: [:], data: Data()) + } + XCTFail("unexpected network call: \(request.url?.absoluteString ?? "nil")") + return .init(statusCode: 500, headers: [:], data: Data()) + } + + let sut = AuthClientFactory.make( + repository: try makeRepository(local: local), + credentialProvider: { _ in + XCTFail("credentialProvider must not be called") + return "unused" + } + ) + + let restored = try await sut.restoreSession() + let current = await sut.currentSession() + try await sut.logout() + let afterLogout = await sut.currentSession() + + XCTAssertEqual(restored, existingSession) + XCTAssertEqual(current, existingSession) + XCTAssertNil(afterLogout) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + XCTAssertEqual(AuthURLProtocolStub.requests.first?.url?.path, "/api/auth/logout") + } + + func test_factory_login_kakao가_자격증명_클로저_후_repository_login을_호출() async throws { + let local = makeLocal() + let probe = CredentialProbe() + stubLoginSuccess() + + let sut = AuthClientFactory.make( + repository: try makeRepository(local: local), + credentialProvider: { provider in + await probe.record(provider) + XCTAssertEqual(provider, .kakao) + return "kakao-token" + } + ) + + let session = try await sut.login(.kakao) + let providers = await probe.providers + let stored = try await local.load() + + XCTAssertEqual(session, expectedLoginSession) + XCTAssertEqual(stored, expectedLoginSession) + XCTAssertEqual(providers, [.kakao]) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + XCTAssertEqual(AuthURLProtocolStub.requests.first?.url?.path, "/api/auth/login/kakao") + let body = try XCTUnwrap(AuthURLProtocolStub.requests.first?.httpBody) + let bodyString = try XCTUnwrap(String(bytes: body, encoding: .utf8)) + XCTAssertTrue(bodyString.contains("kakao-token")) + } + + func test_factory_login_apple이_자격증명_클로저_후_repository_login을_호출() async throws { + let local = makeLocal() + let probe = CredentialProbe() + stubLoginSuccess() + + let sut = AuthClientFactory.make( + repository: try makeRepository(local: local), + credentialProvider: { provider in + await probe.record(provider) + XCTAssertEqual(provider, .apple) + return "apple-token" + } + ) + + let session = try await sut.login(.apple) + let providers = await probe.providers + let stored = try await local.load() + + XCTAssertEqual(session, expectedLoginSession) + XCTAssertEqual(stored, expectedLoginSession) + XCTAssertEqual(providers, [.apple]) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + XCTAssertEqual(AuthURLProtocolStub.requests.first?.url?.path, "/api/auth/login/apple") + let body = try XCTUnwrap(AuthURLProtocolStub.requests.first?.httpBody) + let bodyString = try XCTUnwrap(String(bytes: body, encoding: .utf8)) + XCTAssertTrue(bodyString.contains("apple-token")) + } + + // MARK: - Helpers + + private var expectedLoginSession: AuthSession { + AuthSession( + accessToken: "a1", + refreshToken: "r1", + isNewUser: false, + profileCompleted: true + ) + } + + private var existingSession: AuthSession { + AuthSession( + accessToken: "old-a", + refreshToken: "old-r", + isNewUser: true, + profileCompleted: false + ) + } + + private func stubLoginSuccess() { + AuthURLProtocolStub.requestHandler = { _ in + .init( + statusCode: 200, + headers: [:], + data: Data( + """ + { + "accessToken":"a1", + "refreshToken":"r1", + "isNewUser":false, + "profileCompleted":true + } + """.utf8 + ) + ) + } + } + + private func makeLocal() -> AuthLocalDatasource { + AuthLocalDatasource(keychain: InMemoryKeychainStorage()) + } + + private func makeRepository(local: AuthLocalDatasource) throws -> AuthRepositoryImpl { + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let configuration = NetworkConfiguration(baseURL: baseURL) + let plainClient = DefaultNetworkClient.plain( + configuration: configuration, + session: AuthTestSessionFactory.make() + ) + let remote = AuthRemoteDatasource( + plainClient: plainClient, + authedClient: plainClient + ) + return AuthRepositoryImpl(remote: remote, local: local) + } +} + +private actor CredentialProbe { + private(set) var providers: [AuthProvider] = [] + + func record(_ provider: AuthProvider) { + providers.append(provider) + } +} diff --git a/Projects/Data/Tests/Auth/AuthLocalDatasourceTests.swift b/Projects/Data/Tests/Auth/AuthLocalDatasourceTests.swift new file mode 100644 index 0000000..777b3f5 --- /dev/null +++ b/Projects/Data/Tests/Auth/AuthLocalDatasourceTests.swift @@ -0,0 +1,63 @@ +@testable import Data +import Domain +import XCTest + +final class AuthLocalDatasourceTests: XCTestCase { + func test_세션_저장_후_로드() async throws { + let sut = makeSUT() + let session = AuthSession( + accessToken: "access", + refreshToken: "refresh", + isNewUser: true, + profileCompleted: false + ) + + try await sut.save(session) + let loaded = try await sut.load() + + XCTAssertEqual(loaded, session) + } + + func test_세션_삭제_후_nil() async throws { + let sut = makeSUT() + let session = AuthSession( + accessToken: "access", + refreshToken: "refresh", + isNewUser: false, + profileCompleted: true + ) + try await sut.save(session) + + try await sut.clear() + let loaded = try await sut.load() + + XCTAssertNil(loaded) + } + + func test_accessToken은_세션_accessToken을_반환() async throws { + let sut = makeSUT() + let session = AuthSession( + accessToken: "access-token-value", + refreshToken: "refresh", + isNewUser: false, + profileCompleted: true + ) + try await sut.save(session) + + let token = try await sut.accessToken() + + XCTAssertEqual(token, "access-token-value") + } + + func test_세션_없으면_accessToken_nil() async throws { + let sut = makeSUT() + + let token = try await sut.accessToken() + + XCTAssertNil(token) + } + + private func makeSUT() -> AuthLocalDatasource { + AuthLocalDatasource(keychain: InMemoryKeychainStorage()) + } +} diff --git a/Projects/Data/Tests/Auth/AuthRepositoryImplTests.swift b/Projects/Data/Tests/Auth/AuthRepositoryImplTests.swift new file mode 100644 index 0000000..30a6b7d --- /dev/null +++ b/Projects/Data/Tests/Auth/AuthRepositoryImplTests.swift @@ -0,0 +1,227 @@ +@testable import Data +import CoreNetwork +import Domain +import Foundation +import XCTest + +final class AuthRepositoryImplTests: XCTestCase { + override func setUp() async throws { + AuthURLProtocolStub.reset() + } + + override func tearDown() async throws { + AuthURLProtocolStub.reset() + } + + func test_카카오_로그인_성공_시_세션_저장() async throws { + let local = makeLocal() + stubLoginSuccess() + let sut = try makeSUT(local: local) + + let session = try await sut.loginWithKakao(accessToken: "kakao-token") + + XCTAssertEqual(session, expectedLoginSession) + let stored = try await local.load() + XCTAssertEqual(stored, expectedLoginSession) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + XCTAssertEqual(AuthURLProtocolStub.requests.first?.url?.path, "/api/auth/login/kakao") + } + + func test_애플_로그인_성공_시_세션_저장() async throws { + let local = makeLocal() + stubLoginSuccess() + let sut = try makeSUT(local: local) + + let session = try await sut.loginWithApple(identityToken: "apple-token") + + XCTAssertEqual(session, expectedLoginSession) + let stored = try await local.load() + XCTAssertEqual(stored, expectedLoginSession) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + XCTAssertEqual(AuthURLProtocolStub.requests.first?.url?.path, "/api/auth/login/apple") + } + + func test_dev_로그인_성공_시_세션_저장() async throws { + let local = makeLocal() + stubLoginSuccess() + let sut = try makeSUT(local: local) + + let session = try await sut.loginWithDev() + + XCTAssertEqual(session, expectedLoginSession) + let stored = try await local.load() + XCTAssertEqual(stored, expectedLoginSession) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + XCTAssertEqual(AuthURLProtocolStub.requests.first?.url?.path, "/api/auth/login/dev") + } + + func test_restoreSession은_로컬만_읽고_네트워크_호출_없음() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { _ in + XCTFail("restoreSession must not call network") + return .init(statusCode: 500, headers: [:], data: Data()) + } + let sut = try makeSUT(local: local) + + let restored = try await sut.restoreSession() + + XCTAssertEqual(restored, existingSession) + XCTAssertTrue(AuthURLProtocolStub.requests.isEmpty) + } + + func test_logout_원격_실패해도_로컬_세션_삭제() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { _ in + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet) + } + let sut = try makeSUT(local: local) + + try await sut.logout() + + let stored = try await local.load() + XCTAssertNil(stored) + } + + func test_login_네트워크_실패면_AuthError_network() async throws { + let local = makeLocal() + AuthURLProtocolStub.requestHandler = { _ in + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + } + let sut = try makeSUT(local: local) + + do { + _ = try await sut.loginWithKakao(accessToken: "kakao-token") + XCTFail("expected network") + } catch let error as AuthError { + XCTAssertEqual(error, .network) + } catch { + XCTFail("unexpected \(error)") + } + + let stored = try await local.load() + XCTAssertNil(stored) + } + + func test_login_401이면_AuthError_loginFailed() async throws { + let local = makeLocal() + AuthURLProtocolStub.requestHandler = { _ in + .init( + statusCode: 401, + headers: [:], + data: Data(#"{"message":"invalid token"}"#.utf8) + ) + } + let sut = try makeSUT(local: local) + + do { + _ = try await sut.loginWithApple(identityToken: "apple-token") + XCTFail("expected loginFailed") + } catch let error as AuthError { + XCTAssertEqual(error, .loginFailed) + } catch { + XCTFail("unexpected \(error)") + } + + let stored = try await local.load() + XCTAssertNil(stored) + } + + func test_restoreSession_키체인_실패면_AuthError_storage() async throws { + let local = AuthLocalDatasource( + keychain: FailingKeychainStorage(failingOperations: [.get]) + ) + let sut = try makeSUT(local: local) + + do { + _ = try await sut.restoreSession() + XCTFail("expected storage") + } catch let error as AuthError { + guard case .storage = error else { + return XCTFail("unexpected \(error)") + } + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_logout_키체인_삭제_실패면_AuthError_storage() async throws { + let local = AuthLocalDatasource( + keychain: FailingKeychainStorage(failingOperations: [.delete]) + ) + AuthURLProtocolStub.requestHandler = { _ in + .init(statusCode: 200, headers: [:], data: Data()) + } + let sut = try makeSUT(local: local) + + do { + try await sut.logout() + XCTFail("expected storage") + } catch let error as AuthError { + guard case .storage = error else { + return XCTFail("unexpected \(error)") + } + } catch { + XCTFail("unexpected \(error)") + } + } + + // MARK: - Helpers + + private var expectedLoginSession: AuthSession { + AuthSession( + accessToken: "a1", + refreshToken: "r1", + isNewUser: false, + profileCompleted: true + ) + } + + private var existingSession: AuthSession { + AuthSession( + accessToken: "old-a", + refreshToken: "old-r", + isNewUser: true, + profileCompleted: false + ) + } + + private func stubLoginSuccess() { + AuthURLProtocolStub.requestHandler = { _ in + .init( + statusCode: 200, + headers: [:], + data: Data( + """ + { + "accessToken":"a1", + "refreshToken":"r1", + "isNewUser":false, + "profileCompleted":true + } + """.utf8 + ) + ) + } + } + + private func makeLocal() -> AuthLocalDatasource { + AuthLocalDatasource(keychain: InMemoryKeychainStorage()) + } + + private func makeSUT(local: AuthLocalDatasource) throws -> AuthRepositoryImpl { + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let configuration = NetworkConfiguration(baseURL: baseURL) + let plainClient = DefaultNetworkClient.plain( + configuration: configuration, + session: AuthTestSessionFactory.make() + ) + // logout 실패/성공 검증에는 authed 조립 순환이 필요 없으므로 plain을 재사용한다. + let remote = AuthRemoteDatasource( + plainClient: plainClient, + authedClient: plainClient + ) + return AuthRepositoryImpl(remote: remote, local: local) + } +} diff --git a/Projects/Data/Tests/Auth/AuthTokenRefresherTests.swift b/Projects/Data/Tests/Auth/AuthTokenRefresherTests.swift new file mode 100644 index 0000000..ce9afe0 --- /dev/null +++ b/Projects/Data/Tests/Auth/AuthTokenRefresherTests.swift @@ -0,0 +1,181 @@ +@testable import Data +import CoreNetwork +import Domain +import Foundation +import XCTest + +final class AuthTokenRefresherTests: XCTestCase { + override func setUp() async throws { + AuthURLProtocolStub.reset() + } + + override func tearDown() async throws { + AuthURLProtocolStub.reset() + } + + func test_refresh_성공_시_새_refreshToken으로_교체_저장() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { request in + XCTAssertEqual(request.url?.path, "/api/auth/refresh") + return .init( + statusCode: 200, + headers: [:], + data: Data(#"{"accessToken":"a2","refreshToken":"r2"}"#.utf8) + ) + } + let sut = try makeSUT(local: local) + + try await sut.refresh() + + let stored = try await local.load() + XCTAssertEqual( + stored, + AuthSession( + accessToken: "a2", + refreshToken: "r2", + isNewUser: true, + profileCompleted: false + ) + ) + XCTAssertEqual(AuthURLProtocolStub.requests.count, 1) + } + + func test_refresh_401이면_세션_삭제_후_unauthorized() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { _ in + .init( + statusCode: 401, + headers: [:], + data: Data(#"{"message":"expired"}"#.utf8) + ) + } + let sut = try makeSUT(local: local) + + do { + try await sut.refresh() + XCTFail("expected unauthorized") + } catch let error as AuthError { + XCTAssertEqual(error, .unauthorized) + } catch { + XCTFail("unexpected \(error)") + } + + let stored = try await local.load() + XCTAssertNil(stored) + } + + func test_refresh_네트워크_실패면_세션_유지_후_network() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { _ in + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + } + let sut = try makeSUT(local: local) + + do { + try await sut.refresh() + XCTFail("expected network") + } catch let error as AuthError { + XCTAssertEqual(error, .network) + } catch { + XCTFail("unexpected \(error)") + } + + let stored = try await local.load() + XCTAssertEqual(stored, existingSession) + } + + func test_세션_없으면_unauthorized_이고_네트워크_호출_없음() async throws { + AuthURLProtocolStub.requestHandler = { _ in + XCTFail("refresh without session must not call network") + return .init(statusCode: 500, headers: [:], data: Data()) + } + let sut = try makeSUT(local: makeLocal()) + + do { + try await sut.refresh() + XCTFail("expected unauthorized") + } catch let error as AuthError { + XCTAssertEqual(error, .unauthorized) + } catch { + XCTFail("unexpected \(error)") + } + + XCTAssertTrue(AuthURLProtocolStub.requests.isEmpty) + } + + func test_refresh_400이면_세션_유지_후_unknown() async throws { + let local = makeLocal() + try await local.save(existingSession) + AuthURLProtocolStub.requestHandler = { _ in + .init( + statusCode: 400, + headers: [:], + data: Data(#"{"message":"bad request"}"#.utf8) + ) + } + let sut = try makeSUT(local: local) + + do { + try await sut.refresh() + XCTFail("expected unknown") + } catch let error as AuthError { + guard case .unknown = error else { + return XCTFail("unexpected \(error)") + } + } catch { + XCTFail("unexpected \(error)") + } + + let stored = try await local.load() + XCTAssertEqual(stored, existingSession) + } + + func test_refresh_키체인_로드_실패면_AuthError_storage() async throws { + let local = AuthLocalDatasource( + keychain: FailingKeychainStorage(failingOperations: [.get]) + ) + let sut = try makeSUT(local: local) + + do { + try await sut.refresh() + XCTFail("expected storage") + } catch let error as AuthError { + guard case .storage = error else { + return XCTFail("unexpected \(error)") + } + } catch { + XCTFail("unexpected \(error)") + } + } + + private var existingSession: AuthSession { + AuthSession( + accessToken: "old-a", + refreshToken: "old-r", + isNewUser: true, + profileCompleted: false + ) + } + + private func makeLocal() -> AuthLocalDatasource { + AuthLocalDatasource(keychain: InMemoryKeychainStorage()) + } + + private func makeSUT(local: AuthLocalDatasource) throws -> AuthTokenRefresher { + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let configuration = NetworkConfiguration(baseURL: baseURL) + let plainClient = DefaultNetworkClient.plain( + configuration: configuration, + session: AuthTestSessionFactory.make() + ) + // refresh는 plain only. authed 순환을 피하기 위해 plain을 재사용한다. + let remote = AuthRemoteDatasource( + plainClient: plainClient, + authedClient: plainClient + ) + return AuthTokenRefresher(remote: remote, local: local) + } +} diff --git a/Projects/Data/Tests/Auth/LoginResponseDTOMappingTests.swift b/Projects/Data/Tests/Auth/LoginResponseDTOMappingTests.swift new file mode 100644 index 0000000..73d2fbe --- /dev/null +++ b/Projects/Data/Tests/Auth/LoginResponseDTOMappingTests.swift @@ -0,0 +1,46 @@ +@testable import Data +import Domain +import Foundation +import XCTest + +final class LoginResponseDTOMappingTests: XCTestCase { + func test_LoginResponseDTO가_AuthSession으로_매핑() throws { + let json = Foundation.Data(""" + { + "accessToken": "a", + "refreshToken": "r", + "isNewUser": true, + "profileCompleted": false + } + """.utf8) + let dto = try JSONDecoder().decode(LoginResponseDTO.self, from: json) + XCTAssertEqual( + dto.toDomain(), + AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: true, + profileCompleted: false + ) + ) + } + + func test_TokenResponseDTO가_기존_세션_플래그를_유지한_채_토큰만_교체() { + let old = AuthSession( + accessToken: "old-a", + refreshToken: "old-r", + isNewUser: true, + profileCompleted: false + ) + let dto = TokenResponseDTO(accessToken: "new-a", refreshToken: "new-r") + XCTAssertEqual( + dto.applying(to: old), + AuthSession( + accessToken: "new-a", + refreshToken: "new-r", + isNewUser: true, + profileCompleted: false + ) + ) + } +} diff --git a/Projects/Data/Tests/Auth/OAuthServiceFactoryTests.swift b/Projects/Data/Tests/Auth/OAuthServiceFactoryTests.swift new file mode 100644 index 0000000..255c6f1 --- /dev/null +++ b/Projects/Data/Tests/Auth/OAuthServiceFactoryTests.swift @@ -0,0 +1,37 @@ +@testable import Data +import Domain +import XCTest + +final class OAuthServiceFactoryTests: XCTestCase { + func test_카카오_stub_로그인이면_notConfigured() async { + let services = OAuthServiceFactory.makeStub() + + do { + _ = try await services.service(for: .kakao).login() + XCTFail("expected notConfigured") + } catch let error as AuthError { + XCTAssertEqual( + error, + .notConfigured(message: "Kakao login is not configured yet") + ) + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_애플_stub_로그인이면_notConfigured() async { + let services = OAuthServiceFactory.makeStub() + + do { + _ = try await services.service(for: .apple).login() + XCTFail("expected notConfigured") + } catch let error as AuthError { + XCTAssertEqual( + error, + .notConfigured(message: "Apple login is not configured yet") + ) + } catch { + XCTFail("unexpected \(error)") + } + } +} diff --git a/Projects/Data/Tests/Support/AuthURLProtocolStub.swift b/Projects/Data/Tests/Support/AuthURLProtocolStub.swift new file mode 100644 index 0000000..e26a89f --- /dev/null +++ b/Projects/Data/Tests/Support/AuthURLProtocolStub.swift @@ -0,0 +1,103 @@ +import Foundation + +final class AuthURLProtocolStub: URLProtocol, @unchecked Sendable { + struct StubResponse: Sendable { + let statusCode: Int + let headers: [String: String] + let data: Data + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var _requestHandler: + (@Sendable (URLRequest) throws -> StubResponse)? + nonisolated(unsafe) private static var _requests: [URLRequest] = [] + + static var requestHandler: (@Sendable (URLRequest) throws -> StubResponse)? { + get { lock.withLock { _requestHandler } } + set { lock.withLock { _requestHandler = newValue } } + } + + static var requests: [URLRequest] { + lock.withLock { _requests } + } + + static func reset() { + lock.withLock { + _requestHandler = nil + _requests.removeAll() + } + } + + override static func canInit(with request: URLRequest) -> Bool { true } + override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let observedRequest = Self.materializedRequest(from: request) + Self.lock.withLock { Self._requests.append(observedRequest) } + + do { + guard let handler = Self.requestHandler else { + throw NSError(domain: "AuthURLProtocolStub", code: 1) + } + let stub = try handler(observedRequest) + guard let url = observedRequest.url ?? URL(string: "https://example.invalid") else { + throw NSError(domain: "AuthURLProtocolStub", code: 2) + } + guard let response = HTTPURLResponse( + url: url, + statusCode: stub.statusCode, + httpVersion: "HTTP/1.1", + headerFields: stub.headers + ) else { + throw NSError(domain: "AuthURLProtocolStub", code: 3) + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + private static func materializedRequest(from request: URLRequest) -> URLRequest { + var copy = request + if copy.httpBody == nil, let stream = copy.httpBodyStream { + stream.open() + defer { stream.close() } + + let bufferSize = 1024 + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let readCount = stream.read(buffer, maxLength: bufferSize) + if readCount > 0 { + data.append(buffer, count: readCount) + } else { + break + } + } + copy.httpBody = data + } + return copy + } +} + +enum AuthTestSessionFactory { + static func make() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [AuthURLProtocolStub.self] + return URLSession(configuration: config) + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/Projects/Data/Tests/Support/FailingKeychainStorage.swift b/Projects/Data/Tests/Support/FailingKeychainStorage.swift new file mode 100644 index 0000000..0b8e4e4 --- /dev/null +++ b/Projects/Data/Tests/Support/FailingKeychainStorage.swift @@ -0,0 +1,41 @@ +import CoreStorage +import Foundation + +actor FailingKeychainStorage: KeychainStorage { + enum Operation: Sendable { + case get + case delete + case save + } + + private let failingOperations: Set + + init(failingOperations: Set) { + self.failingOperations = failingOperations + } + + func save(_ value: some Codable & Sendable, forKey key: String) async throws { + if failingOperations.contains(.save) { + throw KeychainError.saveFailed(status: -1) + } + } + + func get(forKey key: String) async throws -> T? { + if failingOperations.contains(.get) { + throw KeychainError.loadFailed(status: -1) + } + return nil + } + + func delete(forKey key: String) async throws { + if failingOperations.contains(.delete) { + throw KeychainError.deleteFailed(status: -1) + } + } + + func deleteAll() async throws { + if failingOperations.contains(.delete) { + throw KeychainError.deleteFailed(status: -1) + } + } +} diff --git a/Projects/Data/Tests/Support/InMemoryKeychainStorage.swift b/Projects/Data/Tests/Support/InMemoryKeychainStorage.swift new file mode 100644 index 0000000..11df00f --- /dev/null +++ b/Projects/Data/Tests/Support/InMemoryKeychainStorage.swift @@ -0,0 +1,25 @@ +import CoreStorage +import Foundation + +actor InMemoryKeychainStorage: KeychainStorage { + private var storage: [String: Data] = [:] + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + func save(_ value: some Codable & Sendable, forKey key: String) async throws { + storage[key] = try encoder.encode(value) + } + + func get(forKey key: String) async throws -> T? { + guard let data = storage[key] else { return nil } + return try decoder.decode(T.self, from: data) + } + + func delete(forKey key: String) async throws { + storage[key] = nil + } + + func deleteAll() async throws { + storage.removeAll() + } +} diff --git a/Projects/Domain/Project.swift b/Projects/Domain/Project.swift index 25a8d47..91acf2d 100644 --- a/Projects/Domain/Project.swift +++ b/Projects/Domain/Project.swift @@ -6,5 +6,6 @@ let project = ProjectFactory.framework( dependencies: [ .sharedUtils, .thirdParty, - ] + ], + includesTests: true ) diff --git a/Projects/Domain/README.md b/Projects/Domain/README.md index 995e378..2123bb9 100644 --- a/Projects/Domain/README.md +++ b/Projects/Domain/README.md @@ -4,7 +4,7 @@ - Entity, Error, Domain port (`*Client`) ## 현재 상태 -- placeholder 골격 +- Auth 포트 추가 (`AuthSession`, `AuthError`, `AuthProvider`, `AuthClient`) ## 이후 패턴 - `Domain//{Model,Client,Error}` @@ -15,15 +15,19 @@ ## 내부 규칙 - 포트 이름은 `*Client` +- Domain Client 는 `@DependencyClient` 매크로 사용 - Feature 가 의존하는 유일한 도메인 경계 - UseCase 층 없음 ## 주요 진입점 -- (현재) `Sources/Placeholder.swift` -- (이후) `*Client` 정의 +- `Sources/Auth/AuthClient.swift` +- `Sources/Auth/AuthSession.swift` +- `Sources/Auth/AuthError.swift` +- `Sources/Auth/AuthProvider.swift` ## 테스트 포인트 -- 순수 모델/ 에러 동작 (구현 시) +- 세션 동등성/Codable 왕복 +- `AuthClient.testValue` 기본 unimplemented 동작 (`@DependencyClient`) ## 관련 문서 - [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) diff --git a/Projects/Domain/Sources/Auth/AuthClient.swift b/Projects/Domain/Sources/Auth/AuthClient.swift new file mode 100644 index 0000000..2a2d04a --- /dev/null +++ b/Projects/Domain/Sources/Auth/AuthClient.swift @@ -0,0 +1,21 @@ +import Foundation +import ThirdParty + +@DependencyClient +public struct AuthClient: Sendable { + public var restoreSession: @Sendable () async throws -> AuthSession? + public var login: @Sendable (AuthProvider) async throws -> AuthSession + public var logout: @Sendable () async throws -> Void + public var currentSession: @Sendable () async -> AuthSession? +} + +extension AuthClient: TestDependencyKey { + public static let testValue = AuthClient() +} + +public extension DependencyValues { + var authClient: AuthClient { + get { self[AuthClient.self] } + set { self[AuthClient.self] = newValue } + } +} diff --git a/Projects/Domain/Sources/Auth/AuthError.swift b/Projects/Domain/Sources/Auth/AuthError.swift new file mode 100644 index 0000000..e496f60 --- /dev/null +++ b/Projects/Domain/Sources/Auth/AuthError.swift @@ -0,0 +1,9 @@ +public enum AuthError: Error, Equatable, Sendable { + case cancelled + case notConfigured(message: String) + case loginFailed + case network + case unauthorized + case storage(message: String) + case unknown(message: String) +} diff --git a/Projects/Domain/Sources/Auth/AuthProvider.swift b/Projects/Domain/Sources/Auth/AuthProvider.swift new file mode 100644 index 0000000..b95e724 --- /dev/null +++ b/Projects/Domain/Sources/Auth/AuthProvider.swift @@ -0,0 +1,4 @@ +public enum AuthProvider: Equatable, Hashable, Sendable { + case kakao + case apple +} diff --git a/Projects/Domain/Sources/Auth/AuthSession.swift b/Projects/Domain/Sources/Auth/AuthSession.swift new file mode 100644 index 0000000..1509893 --- /dev/null +++ b/Projects/Domain/Sources/Auth/AuthSession.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct AuthSession: Equatable, Sendable, Codable { + public var accessToken: String + public var refreshToken: String + public var isNewUser: Bool + public var profileCompleted: Bool + + public init( + accessToken: String, + refreshToken: String, + isNewUser: Bool, + profileCompleted: Bool + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.isNewUser = isNewUser + self.profileCompleted = profileCompleted + } +} diff --git a/Projects/Domain/Sources/Placeholder.swift b/Projects/Domain/Sources/Placeholder.swift deleted file mode 100644 index 73b2239..0000000 --- a/Projects/Domain/Sources/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -public enum DomainPlaceholder {} diff --git a/Projects/Domain/Tests/Auth/AuthSessionTests.swift b/Projects/Domain/Tests/Auth/AuthSessionTests.swift new file mode 100644 index 0000000..616cb4e --- /dev/null +++ b/Projects/Domain/Tests/Auth/AuthSessionTests.swift @@ -0,0 +1,39 @@ +import Domain +import XCTest + +final class AuthSessionTests: XCTestCase { + func test_세션_동등성_비교() { + let a = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: true, + profileCompleted: false + ) + let b = AuthSession( + accessToken: "a", + refreshToken: "r", + isNewUser: true, + profileCompleted: false + ) + XCTAssertEqual(a, b) + } + + func test_세션_codable_왕복() throws { + let original = AuthSession( + accessToken: "access", + refreshToken: "refresh", + isNewUser: false, + profileCompleted: true + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AuthSession.self, from: data) + XCTAssertEqual(decoded, original) + } + + func test_AuthClient_testValue는_빈_클라이언트로_생성() { + // @DependencyClient 는 testValue = AuthClient() 를 기본 제공한다. + // 미구현 endpoint 호출 시 issue를 내므로, 생성 가능성만 검증한다. + _ = AuthClient.testValue + XCTAssertTrue(true) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8527fa..44840c2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -6,7 +6,7 @@ 세로 계층 = 모듈 가로 Feature = 폴더 외부 의존성 = ThirdParty* -live 조립 = App only +live 구현 = Data / live 등록 = App only ``` --- @@ -32,7 +32,7 @@ Projects/ | ThirdParty* | 외부 패키지 진입점 | | Domain | Entity, `*Client`, Error | | Core/* | Network/Storage | -| Data | DTO, Datasource, `*RepositoryImpl`, `*ClientFactory` | +| Data | DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, `*Client.live` | | Feature | Root, AppCoordinator, Scene | | App | bootstrap, live 주입, root store | @@ -65,6 +65,7 @@ MoziApp → AppBootstrap → InfraContainer.live() → Dependencies.register + → AuthClient.live(...) → Store(RootFeature) → RootView → AppCoordinatorView ``` @@ -76,7 +77,7 @@ bootstrapping → main(Placeholder) ``` -Auth / MainTab 샘플은 포함하지 않는다. +Auth 인프라는 Domain/Data/App live 등록까지 존재하고, 게이트 UI / MainTab 샘플은 후속이다. --- @@ -94,7 +95,7 @@ storage namespace 는 Bundle ID 재사용. ## 4. 새 기능 1. Domain `{Model,Error,Client}` -2. Data `{DTO,Datasource,RepositoryImpl,ClientFactory}` +2. Data `{DTO,Datasource,RepositoryImpl,ClientFactory,Client.live}` 3. App `Dependencies.register` 4. Feature `Scene/` 5. 필요 시 AppCoordinator/DeepLink diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 6c5382e..71bdcff 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -10,8 +10,8 @@ - Feature 단일 모듈, 기능 분리는 폴더 - Scene 내부 flat (`*Feature`, `*View`) - Domain 포트는 `*Client` -- Data 구현은 `*RepositoryImpl` + `*ClientFactory` -- live 조립은 App only +- Data 구현은 `*RepositoryImpl` + `*ClientFactory` + `*Client.live` +- live 구현 제공은 Data, live 등록/주입은 App only - Feature 는 Domain `*Client` 만 사용 - Scene 통신은 delegate bubble-up - SharedUtils 는 pure Foundation / App metadata 만. UI·I/O·Domain 금지