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()
+ }
+ }
+}