diff --git a/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift b/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift index ca5df2a..614b3ee 100644 --- a/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift +++ b/Common/Sources/DataSources/Keychain/JWTTokenStorageable.swift @@ -5,6 +5,7 @@ // Created by 강동영 on 1/13/26. // +import Foundation public protocol JWTTokenStorageable: Sendable { func save(accessToken: String, refreshToken: String?) throws @@ -13,10 +14,10 @@ public protocol JWTTokenStorageable: Sendable { func exists() throws -> Bool } -public final class JWTokenStorage: KeychainTokenStorage, JWTTokenStorageable { +public final class JWTokenStorage: /*KeychainTokenStorage,*/ JWTTokenStorageable { private let service: String - public override init(service: String = HambugKeychainKey.serviceID) { + public init(service: String = HambugKeychainKey.serviceID) { self.service = service } @@ -44,3 +45,130 @@ public final class JWTokenStorage: KeychainTokenStorage, JWTTokenStorageable { try delete(.refreshToken) } } + +extension JWTokenStorage { + + func set(_ value: String, for key: HambugKeychainKey) throws { + let data = value.data(using: .utf8)! + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.toString, + kSecValueData as String: data + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + do { + try handleError(status) + } catch KeychainError.duplicateItem { + // 중복 된 아이템이 있다면 업데이트 수행 + try updateExisting(data, for: key) + } + } + + func updateExisting(_ data: Data, for key: HambugKeychainKey) throws { + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.toString + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data + ] + + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, attributes as CFDictionary) + + try handleError(updateStatus) + } + + func string(for key: HambugKeychainKey) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.toString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + try handleError(status) + + guard let data = item as? Data, + let string = String(data: data, encoding: .utf8) + else { + throw KeychainError.unexpectedPasswordData + } + return string + } + + func delete(_ key: HambugKeychainKey) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.toString + ] + + let status = SecItemDelete(query as CFDictionary) + + try handleError(status) + } + + func contains(_ key: HambugKeychainKey) throws -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key.toString, + ] + + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } + + func handleError(_ status: OSStatus) throws { + switch status { + case errSecSuccess: + return + case errSecDuplicateItem: + throw KeychainError.duplicateItem + case errSecItemNotFound: + throw KeychainError.itemNotFound + default: + throw KeychainError.unexpected(status) + } + } +} + +//public final class JWTokenStorage: KeychainTokenStorage, JWTTokenStorageable { +// private let service: String +// +// public override init(service: String = HambugKeychainKey.serviceID) { +// self.service = service +// } +// +// public func save(accessToken: String, refreshToken: String?) throws { +// try set(accessToken, for: .accessToken) +// +// if let refresh = refreshToken { +// try set(refresh, for: .refreshToken) +// } +// } +// +// public func load() -> (accessToken: String?, refreshToken: String?) { +// let access = try? string(for: .accessToken) +// let refresh = try? string(for: .refreshToken) +// +// return (access, refresh) +// } +// +// public func exists() throws -> Bool { +// try contains(.accessToken) +// } +// +// public func clear() throws { +// try delete(.accessToken) +// try delete(.refreshToken) +// } +//} diff --git a/Hambug.xcodeproj/xcshareddata/xcschemes/HambugUITests.xcscheme b/Hambug.xcodeproj/xcshareddata/xcschemes/HambugUITests.xcscheme new file mode 100644 index 0000000..613b4c9 --- /dev/null +++ b/Hambug.xcodeproj/xcshareddata/xcschemes/HambugUITests.xcscheme @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HambugUITests/Core/Base/TestUIBase.swift b/HambugUITests/Core/Base/TestUIBase.swift new file mode 100644 index 0000000..f442245 --- /dev/null +++ b/HambugUITests/Core/Base/TestUIBase.swift @@ -0,0 +1,70 @@ +// +// TestUIBase.swift +// Hambug +// +// Created by 강동영 on 2/7/26. +// + +import XCTest + +class TestUIBase: XCTestCase { + static var launched = false + + var app: XCUIApplication? + + convenience init(app: XCUIApplication?) { + self.init() + self.app = app + } + + override func setUp() { + super.setUp() + // 앱 Launch 여부(시간 단축용) + if TestUIBase.launched == false { + initApp(withLaunch: true) + TestUIBase.launched = true + } else { + initApp(withLaunch: false) + } + } + + private func initApp(withLaunch: Bool) { + guard app == nil else { return } + let application = XCUIApplication() + if withLaunch { + application.launchArguments.append("UITest") + application.launch() + } + app = application + } + + override func tearDown() { + super.tearDown() + if let cnt = testRun?.failureCount, cnt > 0 { + // 한번이라도 실패시 다시 Launch토록 설정 + TestUIBase.launched = false + } + } + + enum DelayType: TimeInterval { + case short = 0.7 + case medium = 1.3 + case long = 1.6 + } + + /// 딜레이 걸기 + func delay(_ timeout: TimeInterval) { + wait(for: [.delay], timeout: timeout) + } + func delay(_ type: DelayType) { + wait(for: [.delay], timeout: type.rawValue) + } + + /// 스크린샷 + func takeScreenshot(name: String) { + let fullScreenshot = XCUIScreen.main.screenshot() + let screenshot = XCTAttachment(uniformTypeIdentifier: "public.png", name: "Screenshot-\(name)-\(UIDevice.current.name).png", payload: fullScreenshot.pngRepresentation, userInfo: nil) + screenshot.lifetime = .keepAlways + add(screenshot) + } +} diff --git a/HambugUITests/Core/Extensions/XCTestExpectation+.swift b/HambugUITests/Core/Extensions/XCTestExpectation+.swift new file mode 100644 index 0000000..2ffd678 --- /dev/null +++ b/HambugUITests/Core/Extensions/XCTestExpectation+.swift @@ -0,0 +1,16 @@ +// +// XCTestExpectation+.swift +// Hambug +// +// Created by 강동영 on 2/7/26. +// + +import XCTest + +extension XCTestExpectation { + static var delay: XCTestExpectation { + let delay = XCTestExpectation() + delay.isInverted = true + return delay + } +} diff --git a/HambugUITests/Core/Protocols/UITestProtocols.swift b/HambugUITests/Core/Protocols/UITestProtocols.swift new file mode 100644 index 0000000..abbd839 --- /dev/null +++ b/HambugUITests/Core/Protocols/UITestProtocols.swift @@ -0,0 +1,102 @@ +// +// UITestProtocols.swift +// Hambug +// +// Created by 강동영 on 2/7/26. +// + +import XCTest + +/// 키보드 타이핑 가능 +protocol UITypingAvailable { + func typingText(_ text: String) +} + +extension UITypingAvailable where Self: TestUIBase { + func typingText(_ text: String) { + guard let app = app else { + XCTAssert(false, "app 초기화 안됌") + return + } + + text.forEach { + app.keyboards.keys[String($0)].tap() + } + } + + func deleteText(repeatCnt: Int) { + let key = "Delete" + for _ in 0.. 0, + cellCnt > index else { return } + app?.tables.cells.allElementsBoundByIndex[index].tap() + } +} + +/// CollectionView Cell 선택 +protocol UISelectCollectionCellAvailable { + func selectCollectionCell(index: Int) + func selectCollectionCell(text: String) +} + +extension UISelectCollectionCellAvailable where Self: TestUIBase { + func selectCollectionCell(text: String) { + app?.collectionViews.cells.staticTexts[text].firstMatch.tap() + } + + func selectCollectionCell(index: Int) { + guard let cellCnt = app?.collectionViews.cells.count, + cellCnt > 0, + cellCnt > index else { return } + app?.collectionViews.cells.allElementsBoundByIndex[index].tap() + } +} diff --git a/HambugUITests/HambugUITests.swift b/HambugUITests/HambugUITests.swift deleted file mode 100644 index 0b32dd1..0000000 --- a/HambugUITests/HambugUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// HambugUITests.swift -// HambugUITests -// -// Created by 차상진 on 8/1/25. -// - -import XCTest - -final class HambugUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } -} diff --git a/HambugUITests/HambugUITestsLaunchTests.swift b/HambugUITests/HambugUITestsLaunchTests.swift deleted file mode 100644 index 4f144b9..0000000 --- a/HambugUITests/HambugUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// HambugUITestsLaunchTests.swift -// HambugUITests -// -// Created by 차상진 on 8/1/25. -// - -import XCTest - -final class HambugUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/HambugUITests/Identifiers/TestIdentifier.swift b/HambugUITests/Identifiers/TestIdentifier.swift new file mode 100644 index 0000000..7ec0975 --- /dev/null +++ b/HambugUITests/Identifiers/TestIdentifier.swift @@ -0,0 +1,23 @@ +// +// TestIdentifier.swift +// HambugUITests +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +enum TestIdentifier { + static let springboard = "com.apple.springboard" + static let safariview = "com.apple.SafariViewService" + + // MARK: - 온보딩 + static let nextButton = "다음" + static let allowNotificationButton = "허용" + + // MARK: - 로그인 + static let kakaoLoginButton = "카카오 로그인" + static let popUpContinueButton = "계속" + static let webViewContinueButton = "계속하기" + static let hambugTitleText = "햄버그" +} diff --git a/HambugUITests/TestPage/Scenario/TestUIScenarioIntro.swift b/HambugUITests/TestPage/Scenario/TestUIScenarioIntro.swift new file mode 100644 index 0000000..63b7b56 --- /dev/null +++ b/HambugUITests/TestPage/Scenario/TestUIScenarioIntro.swift @@ -0,0 +1,24 @@ +// +// TestUIScenarioIntro.swift +// HambugUITests +// +// Created by 강동영 on 2/7/26. +// + +import XCTest + +class TestUIScenarioOnboarding: TestUIBase { + func testOnboarding() { + let onboardingView = UIBaseOnboardingView(app: app) + onboardingView.startOnboarding() + } +} + +class TestUIScenarioLogin: TestUIBase { + func testKakaoLogin() { + let onboardingView = UIBaseOnboardingView(app: app) + onboardingView.startOnboarding() + let loginView = UIBaseLoginView(app: app) + loginView.login() + } +} diff --git a/HambugUITests/TestPage/UIBase/UIBaseLoginView.swift b/HambugUITests/TestPage/UIBase/UIBaseLoginView.swift new file mode 100644 index 0000000..8c35a3f --- /dev/null +++ b/HambugUITests/TestPage/UIBase/UIBaseLoginView.swift @@ -0,0 +1,80 @@ +// +// UIBaseLoginView.swift +// HambugUITests +// +// Created by 강동영 on 2/7/26. +// + +import XCTest + +final class UIBaseLoginView: TestUIBase { + func login() { + delay(.medium) + tap(.kakaoLoginButton) + + delay(.medium) + tap(.popUpContinueButton) + + delay(.medium) + tap(.webViewContinueButton) + + delay(.long) + delay(.long) + checkExist(.hambugTitleText, isExist: true) + } +} + +extension UIBaseLoginView: UITapAvailable { + typealias TapAvailables = TapAvailable + + enum TapAvailable { + case kakaoLoginButton + case popUpContinueButton + case webViewContinueButton + } + + func tap(_ tap: TapAvailable) { + guard let app = app else { + XCTAssert(false, "app 초기화 안됌") + return + } + + switch tap { + case .kakaoLoginButton: + let 카카오로그인 = TestIdentifier.kakaoLoginButton + app.buttons[카카오로그인].tap() + + case .popUpContinueButton: + XCUIDevice.shared.press(.home) + let springboardApp = XCUIApplication(bundleIdentifier: TestIdentifier.springboard) + let 계속 = TestIdentifier.popUpContinueButton + springboardApp.buttons[계속].tap() + + case .webViewContinueButton: + let safariViewServiceApp = XCUIApplication(bundleIdentifier: TestIdentifier.safariview) + let 계속하기 = TestIdentifier.webViewContinueButton + safariViewServiceApp.buttons[계속하기].tap() + } + } +} + +extension UIBaseLoginView: UICheckExistAvailable { + typealias CheckExistAvailables = CheckExistAvailable + + enum CheckExistAvailable { + case hambugTitleText + } + + func checkExist(_ object: CheckExistAvailable, isExist: Bool) { + guard let app = app else { + XCTAssert(false, "app 초기화 안됌") + return + } + + switch object { + case .hambugTitleText: + let 햄버그 = TestIdentifier.hambugTitleText + XCTAssert(app.staticTexts[햄버그].exists == isExist) + } + } +} diff --git a/HambugUITests/TestPage/UIBase/UIBaseOnboardingView.swift b/HambugUITests/TestPage/UIBase/UIBaseOnboardingView.swift new file mode 100644 index 0000000..b1f992f --- /dev/null +++ b/HambugUITests/TestPage/UIBase/UIBaseOnboardingView.swift @@ -0,0 +1,50 @@ +// +// UIBaseOnboardingView.swift +// Hambug +// +// Created by 강동영 on 2/7/26. +// + +import XCTest + +final class UIBaseOnboardingView: TestUIBase { + func startOnboarding() { + delay(.medium) + tap(.allowNotificationButton) + + delay(.medium) + + tap(.nextButton) + delay(.medium) + tap(.nextButton) + delay(.medium) + tap(.nextButton) + delay(.medium) + } +} + +extension UIBaseOnboardingView: UITapAvailable { + typealias TapAvailables = TapAvailable + + enum TapAvailable { + case nextButton + case allowNotificationButton + } + + func tap(_ tap: TapAvailable) { + guard let app = app else { + XCTAssert(false, "app 초기화 안됌") + return + } + switch tap { + case .nextButton: + let 다음 = TestIdentifier.nextButton + app.buttons[다음].tap() + + case .allowNotificationButton: + let springboardApp = XCUIApplication(bundleIdentifier: TestIdentifier.springboard) + let 허용 = TestIdentifier.allowNotificationButton + springboardApp.buttons[허용].tap() + } + } +}