diff --git a/AGENT_ROLES.md b/AGENT_ROLES.md index 79296315..bcf92d94 100644 --- a/AGENT_ROLES.md +++ b/AGENT_ROLES.md @@ -44,6 +44,15 @@ Default role-to-model assignment: Do not assign `Spark` as the only model for production Swift implementation, target dependency changes, DI assembly, repository/service contract changes, Firebase or SDK placement, Widget data-flow changes, StorePattern responsibility changes, commits, pushes, PR creation, or final integration. +### Model dispatch requirements + +- A model tier assignment is an execution requirement, not a label for work the main agent already performed. +- When a role is assigned to `Spark` or `Fast`, and tooling can select that model, the main agent must dispatch that role through a separate model or sub-agent call before using its result. +- Do not satisfy a `Spark` or `Fast` role by completing the role directly in `Primary` and describing it as delegated work. +- If the assigned model cannot be called because the model or model-selecting tool is unavailable, apply the fallback policy and report which role was not dispatched to its default tier. +- If the assigned model is available but current tool policy requires explicit user permission before dispatch, missing permission is not fallback. Stop and ask for permission before continuing the required role. +- `Primary` must integrate and verify delegated output, but must not skip the delegated role when the workflow requires it and the assigned model is available. + ### Fallback policy - If `gpt-5.3-codex-spark` is unavailable, assign `Spark` roles to the fastest available read-only capable coding model. @@ -123,6 +132,7 @@ Rules: - Stay inside the role permissions. - Do not edit files if this is a read-only role. - Do not run, launch, install, boot, or open the app or Simulator. +- Perform this role in the assigned model context. Do not return work copied from a different model context as this role's own result. - Stop and report if the task packet conflicts with `AGENTS.md`. - Return only the output format defined for ``. ``` diff --git a/AGENT_WORKFLOWS.md b/AGENT_WORKFLOWS.md index 3987cf66..c4b3932f 100644 --- a/AGENT_WORKFLOWS.md +++ b/AGENT_WORKFLOWS.md @@ -17,12 +17,14 @@ The main agent must run every workflow with this protocol. 3. Create the task packet. 4. Assign only the roles required by the selected workflow. 5. Assign each role a model tier from `AGENT_ROLES.md`. -6. Dispatch read-only `Spark` or `Fast` roles in parallel only when they do not depend on unfinished edits. -7. Keep `Primary` editing roles sequential unless the files and ownership boundaries are disjoint. -8. Integrate role outputs. -9. Escalate any `Spark` or `Fast` blocker to a `Primary` model before editing. -10. Run completion gates. -11. Report changed files, architecture decision, verification result, and unresolved decisions. +6. Dispatch each `Spark` or `Fast` role through a separate model or sub-agent call when tooling can select that assigned model. +7. Dispatch read-only `Spark` or `Fast` roles in parallel only when they do not depend on unfinished edits. +8. Do not complete a required `Spark` or `Fast` role directly in `Primary` unless the model or model-selecting tool is unavailable and the fallback policy in `AGENT_ROLES.md` applies. +9. Keep `Primary` editing roles sequential unless the files and ownership boundaries are disjoint. +10. Integrate role outputs. +11. Escalate any `Spark` or `Fast` blocker to a `Primary` model before editing. +12. Run completion gates. +13. Report changed files, architecture decision, verification result, delegated roles, model tiers used, and unresolved decisions. Do not skip the task packet. The task packet is the contract between models. @@ -33,6 +35,7 @@ Stop and ask the user before editing when: - The task packet conflicts with `AGENTS.md`. - The requested fix requires relaxing a layer boundary. - A role needs to run, launch, install, boot, or open the app or Simulator. +- A required `Spark` or `Fast` role needs a separate model or sub-agent call, but current tool policy requires user permission that has not been granted. - The current issue or PR scope is unclear after live GitHub inspection. - Two editing roles would touch the same file. - A read-only role reports `Block` or `Needs Owner Decision`. @@ -236,6 +239,9 @@ Use for PR body, issue text, release note, README wording, review reply draft, o ### Execution - Documentation Writer must inspect the actual diff before writing PR or release text. +- When the Documentation Writer role is required and `Spark` is available, the main agent must dispatch the draft to `gpt-5.3-codex-spark` or the configured `Spark` fallback before writing the final response. +- If dispatch requires explicit user permission and it has not been granted, ask before drafting, returning, or posting the Documentation Writer output. +- `Primary` must review the Documentation Writer output against the template, issue scope, and diff before returning or posting it. - Do not write AI workflow documents under `docs/`. - If the user asks only for text, return text directly and do not create files. - If documentation files are changed, keep the change scoped to the requested document. diff --git a/Application/App/Sources/Resource/Localizable.xcstrings b/Application/App/Sources/Resource/Localizable.xcstrings index a687bcb7..4b2792d2 100644 --- a/Application/App/Sources/Resource/Localizable.xcstrings +++ b/Application/App/Sources/Resource/Localizable.xcstrings @@ -139,6 +139,40 @@ } } }, + "account_alert_github_email_conflict_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A GitHub email change caused a conflict during the linking process. To protect existing data, linking cannot be completed." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 이메일 변경으로 연결 과정에서 충돌이 발생했습니다. 기존 데이터를 보호하기 위해 연결을 진행할 수 없습니다." + } + } + } + }, + "account_alert_github_email_conflict_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to Link Account" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계정을 연결할 수 없음" + } + } + } + }, "account_connect" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Data/Sources/Common/DataLayerError.swift b/Application/Data/Sources/Common/DataLayerError.swift index 2cfed763..937c4449 100644 --- a/Application/Data/Sources/Common/DataLayerError.swift +++ b/Application/Data/Sources/Common/DataLayerError.swift @@ -8,31 +8,25 @@ import Foundation import Core -public enum EmailFetchError: Error, Equatable { - case emailNotFound - case emailMismatch - - public var code: String { - switch self { - case .emailMismatch: - "email_mismatch" - case .emailNotFound: - "email_not_found" - } - } +public enum EmailError: Error, Equatable { + case notFound + case mismatch + case githubEmailConflict } -public enum DataError: Error { +public enum DataLayerError: Error { + case notAuthenticated + case linkCredentialAlreadyInUse case invalidData(context: String) - private static let logger = Logger(category: "DataError") + private static let logger = Logger(category: "DataLayerError") public static func invalidData( _ context: String, file: String = #file, function: String = #function, line: Int = #line - ) -> DataError { + ) -> DataLayerError { logger.error( "Invalid data: \(context)", file: file, @@ -42,8 +36,3 @@ public enum DataError: Error { return .invalidData(context: context) } } - -public enum DataLayerError: Error { - case notAuthenticated - case linkCredentialAlreadyInUse -} diff --git a/Application/Data/Sources/Mapper/ErrorMapping.swift b/Application/Data/Sources/Mapper/ErrorMapping.swift index adaf3a2f..ac85f06c 100644 --- a/Application/Data/Sources/Mapper/ErrorMapping.swift +++ b/Application/Data/Sources/Mapper/ErrorMapping.swift @@ -14,6 +14,8 @@ extension Error { return AuthError.notAuthenticated case .linkCredentialAlreadyInUse: return AuthError.linkCredentialAlreadyInUse + case .invalidData(let context): + return DomainLayerError.invalidData(context: context) case .none: return self } diff --git a/Application/Data/Sources/Mapper/PushNotificationMapping.swift b/Application/Data/Sources/Mapper/PushNotificationMapping.swift index 373c503e..8b1848e1 100644 --- a/Application/Data/Sources/Mapper/PushNotificationMapping.swift +++ b/Application/Data/Sources/Mapper/PushNotificationMapping.swift @@ -15,7 +15,7 @@ public extension PushNotificationResponse { case .decoded(let category): todoCategory = category case .raw(let category): - throw DataError.invalidData( + throw DataLayerError.invalidData( "PushNotificationResponse.todoCategory must be resolved before toDomain(): \(category)" ) } diff --git a/Application/Data/Sources/Mapper/TodoMapping.swift b/Application/Data/Sources/Mapper/TodoMapping.swift index 18804df1..e6cec398 100644 --- a/Application/Data/Sources/Mapper/TodoMapping.swift +++ b/Application/Data/Sources/Mapper/TodoMapping.swift @@ -54,7 +54,7 @@ public extension TodoResponse { case .decoded(let category): todoCategory = category case .raw(let category): - throw DataError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)") + throw DataLayerError.invalidData("TodoResponse.category must be resolved before toDomain(): \(category)") } return Todo( diff --git a/Application/Data/Sources/Mapper/WebPageMapping.swift b/Application/Data/Sources/Mapper/WebPageMapping.swift index ed4e5d52..8f67087d 100644 --- a/Application/Data/Sources/Mapper/WebPageMapping.swift +++ b/Application/Data/Sources/Mapper/WebPageMapping.swift @@ -11,10 +11,10 @@ import Domain public extension WebPageResponse { func toDomain() throws -> WebPage { guard let url = URL(string: url) else { - throw DataError.invalidData("WebPageResponse.url is invalid: \(url)") + throw DataLayerError.invalidData("WebPageResponse.url is invalid: \(url)") } guard let displayURL = URL(string: displayURL) else { - throw DataError.invalidData("WebPageResponse.displayURL is invalid: \(displayURL)") + throw DataLayerError.invalidData("WebPageResponse.displayURL is invalid: \(displayURL)") } let imageURL: URL? if !self.imageURL.isEmpty { diff --git a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift index d060bbb0..70c2f8ae 100644 --- a/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthDataRepositoryImpl.swift @@ -85,12 +85,14 @@ final class AuthDataRepositoryImpl: AuthDataRepository { private extension AuthDataRepositoryImpl { func mapLinkError(_ error: Error) -> Error { - if let emailFetchError = error as? EmailFetchError { - switch emailFetchError { - case .emailNotFound: + if let emailError = error as? EmailError { + switch emailError { + case .notFound: return AuthError.linkEmailNotFound - case .emailMismatch: + case .mismatch: return AuthError.linkEmailMismatch + case .githubEmailConflict: + return AuthError.githubEmailConflict } } diff --git a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift index bbbbcdca..7dc3208f 100644 --- a/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -128,8 +128,8 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { private extension AuthenticationRepositoryImpl { func mapSignInError(_ error: Error) -> Error { - if let emailFetchError = error as? EmailFetchError, - emailFetchError == .emailNotFound { + if let emailError = error as? EmailError, + emailError == .notFound { return AuthError.emailNotFound } diff --git a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift index 8344e249..7f9734de 100644 --- a/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/PushNotificationRepositoryImpl.swift @@ -208,7 +208,7 @@ private extension PushNotificationRepositoryImpl { }) { todoCategory = .user(userTodoCategory) } else { - throw DataError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)") + throw DataLayerError.invalidData("PushNotificationResponse.todoCategory is invalid: \(id)") } return PushNotificationResponse( diff --git a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift index 68fb44b1..f7af928a 100644 --- a/Application/Data/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/Data/Sources/Repository/TodoRepositoryImpl.swift @@ -115,7 +115,7 @@ final class TodoRepositoryImpl: TodoRepository { return try responses.reduce(into: [Int: TodoReference]()) { partialResult, pair in let response = try resolve(pair.value, userTodoCategories: userTodoCategories) guard case let .decoded(category) = response.category else { - throw DataError.invalidData("TodoReferenceResponse.category must be resolved before use") + throw DataLayerError.invalidData("TodoReferenceResponse.category must be resolved before use") } partialResult[pair.key] = TodoReference( @@ -208,7 +208,7 @@ private extension TodoRepositoryImpl { }) { category = .user(userTodoCategory) } else { - throw DataError.invalidData("TodoResponse.category is invalid: \(id)") + throw DataLayerError.invalidData("TodoResponse.category is invalid: \(id)") } return TodoResponse( @@ -249,7 +249,7 @@ private extension TodoRepositoryImpl { }) { category = .user(userTodoCategory) } else { - throw DataError.invalidData("TodoReferenceResponse.category is invalid: \(categoryId)") + throw DataLayerError.invalidData("TodoReferenceResponse.category is invalid: \(categoryId)") } return TodoReferenceResponse( diff --git a/Application/Domain/Sources/Entity/AuthError.swift b/Application/Domain/Sources/Entity/AuthError.swift index d23be3f1..4506d38c 100644 --- a/Application/Domain/Sources/Entity/AuthError.swift +++ b/Application/Domain/Sources/Entity/AuthError.swift @@ -12,5 +12,6 @@ public enum AuthError: Error { case linkEmailNotFound case linkEmailMismatch case linkCredentialAlreadyInUse + case githubEmailConflict case unsupportedProvider } diff --git a/Application/Domain/Sources/Entity/DomainLayerError.swift b/Application/Domain/Sources/Entity/DomainLayerError.swift new file mode 100644 index 00000000..af35c33d --- /dev/null +++ b/Application/Domain/Sources/Entity/DomainLayerError.swift @@ -0,0 +1,10 @@ +// +// DomainLayerError.swift +// Domain +// +// Created by opfic on 7/10/26. +// + +public enum DomainLayerError: Error { + case invalidData(context: String) +} diff --git a/Application/Infra/Sources/Service/FunctionAPIClient.swift b/Application/Infra/Sources/Service/FunctionAPIClient.swift index 1fcdf821..45c8a228 100644 --- a/Application/Infra/Sources/Service/FunctionAPIClient.swift +++ b/Application/Infra/Sources/Service/FunctionAPIClient.swift @@ -112,6 +112,12 @@ private struct FunctionAPIErrorBody: Decodable { let message: String? } +private enum FunctionAPIErrorCode: String { + case emailNotFound = "email-not-found" + case emailMismatch = "email-mismatch" + case githubEmailConflict = "github-email-changed-account-conflict" +} + private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { func decodeServerError( data: Data, @@ -121,15 +127,15 @@ private struct FunctionAPIServerErrorDecoder: NXServerErrorDecoder { guard let body = try? decoder.decode( FunctionAPIErrorBody.self, from: data - ) else { return nil } - - switch body.code { - case EmailFetchError.emailNotFound.code: - return EmailFetchError.emailNotFound - case EmailFetchError.emailMismatch.code: - return EmailFetchError.emailMismatch - default: - return nil + ), let code = FunctionAPIErrorCode(rawValue: body.code) else { return nil } + + switch code { + case .emailNotFound: + return EmailError.notFound + case .emailMismatch: + return EmailError.mismatch + case .githubEmailConflict: + return EmailError.githubEmailConflict } } } @@ -145,7 +151,7 @@ private actor FirebaseAuthTokenProvider: NXAuthTokenProvider { } extension Error { - var apiEmailFetchError: EmailFetchError? { + var apiEmailError: EmailError? { guard let error = self as? NXError, case let .server( statusCode: _, @@ -153,6 +159,6 @@ extension Error { underlying: underlying ) = error else { return nil } - return underlying as? EmailFetchError + return underlying as? EmailError } } diff --git a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift index 7c141eb6..8a6ff347 100644 --- a/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift +++ b/Application/Infra/Sources/Service/FunctionAPIEndpoint.swift @@ -42,6 +42,7 @@ extension FunctionAPIEndpoint where Response == FunctionAPIResponse { static let requestAppleCustomToken = Self(method: .post, path: "/auth/apple/custom-token") static let refreshAppleAccessToken = Self(method: .post, path: "/auth/apple/access-token") static let requestAppleRefreshToken = Self(method: .post, path: "/auth/apple/refresh-token") + static let linkGithubProvider = Self(method: .post, path: "/auth/github/link") static let requestGithubTokens = Self(method: .post, path: "/auth/github/tokens") } diff --git a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index b7eb4594..45959f70 100644 --- a/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -150,12 +150,12 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { guard let appleEmail = credential.email else { try await revokeAppleAccessToken(token: refreshToken) - throw EmailFetchError.emailNotFound + throw EmailError.notFound } if appleEmail != email { try await revokeAppleAccessToken(token: refreshToken) - throw EmailFetchError.emailMismatch + throw EmailError.mismatch } let appleCredential = OAuthProvider.credential( diff --git a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index cb91de80..2164fb59 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -122,32 +122,16 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } } - func link(uid: String, email: String) async throws -> Bool { + func link(uid: String, email _: String) async throws -> Bool { logger.info("Linking GitHub account for user: \(uid)") do { let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) let authorizationCode = try await requestAuthorizationCode() - let (accessToken, _) = try await requestTokens(authorizationCode: authorizationCode) - - let githubUser = try await requestUserProfile(accessToken: accessToken) - - guard let githubEmail = githubUser.email else { - logger.error("GitHub email not found") - try await revokeAccessToken(accessToken: accessToken) - throw EmailFetchError.emailNotFound - } - - if githubEmail != email { - logger.error("Email mismatch - Expected: \(email), Got: \(githubEmail)") - try await revokeAccessToken(accessToken: accessToken) - throw EmailFetchError.emailMismatch - } + let accessToken = try await requestProviderLink(authorizationCode: authorizationCode) try await tokensRef.setData(["githubAccessToken": accessToken], merge: true) - - let credential = OAuthProvider.credential(providerID: providerID, accessToken: accessToken) - try await user?.link(with: credential) + try await user?.reload() logger.info("Successfully linked GitHub account") return true @@ -257,7 +241,24 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } throw TokenError.invalidResponse } catch { - throw mapRequestTokensError(error) + throw mapAPIEmailError(error) + } + } + + private func requestProviderLink(authorizationCode: String) async throws -> String { + do { + let response = try await FunctionAPIClient.shared.send( + .linkGithubProvider, + payload: ["code": authorizationCode], + requiresAuthentication: true + ) + + guard let accessToken = response.accessToken else { + throw TokenError.invalidResponse + } + return accessToken + } catch { + throw mapAPIEmailError(error) } } @@ -309,9 +310,9 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { return gitHubEmails.first(where: { $0.verified })?.email } - private func mapRequestTokensError(_ error: Error) -> Error { - if let emailFetchError = error.apiEmailFetchError { - return emailFetchError + private func mapAPIEmailError(_ error: Error) -> Error { + if let emailError = error.apiEmailError { + return emailError } return error diff --git a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index cf2feb85..3c34ff1b 100644 --- a/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/Infra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -123,11 +123,11 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { let signIn = try await GIDSignIn.sharedInstance.signIn(withPresenting: topViewController) guard let googleEmail = signIn.user.profile?.email else { - throw EmailFetchError.emailNotFound + throw EmailError.notFound } if googleEmail != email { - throw EmailFetchError.emailMismatch + throw EmailError.mismatch } guard let idToken = signIn.user.idToken?.tokenString else { diff --git a/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift new file mode 100644 index 00000000..0755ff8c --- /dev/null +++ b/Application/Infra/Tests/Service/FunctionAPIEndpointTests.swift @@ -0,0 +1,19 @@ +// +// FunctionAPIEndpointTests.swift +// InfraTests +// +// Created by opfic on 7/10/26. +// + +import Testing +@testable import Infra + +struct FunctionAPIEndpointTests { + @Test("GitHub provider 연결 endpoint는 인증 link 경로를 사용한다") + func 깃허브_provider_연결_endpoint는_인증_link_경로를_사용한다() { + let endpoint = FunctionAPIEndpoint.linkGithubProvider + + #expect(endpoint.method.rawValue == "POST") + #expect(endpoint.path == "/auth/github/link") + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift index 4eb4bda7..b21eb811 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift @@ -39,6 +39,7 @@ struct AccountFeature { case linkEmailNotFound case linkEmailMismatch case linkCredentialAlreadyInUse + case githubEmailConflict case error } @@ -201,6 +202,8 @@ private extension AccountFeature { return .linkEmailMismatch case .linkCredentialAlreadyInUse: return .linkCredentialAlreadyInUse + case .githubEmailConflict: + return .githubEmailConflict case .notAuthenticated, .failedToUnlinkLastProvider, .emailNotFound, .unsupportedProvider: return .error } @@ -220,6 +223,9 @@ private extension AccountFeature { case .linkCredentialAlreadyInUse: title = String(localized: "account_alert_already_linked_title") message = String(localized: "account_alert_already_linked_message") + case .githubEmailConflict: + title = String(localized: "account_alert_github_email_conflict_title") + message = String(localized: "account_alert_github_email_conflict_message") case .error: title = String(localized: "common_error_title") message = String(localized: "common_error_message") diff --git a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift index 945bef91..bdc2c49b 100644 --- a/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift +++ b/Application/Presentation/ProfileTab/Tests/Settings/AccountFeatureTests.swift @@ -172,6 +172,11 @@ struct AccountFeatureTests { title: String(localized: "account_alert_already_linked_title"), message: String(localized: "account_alert_already_linked_message") ), + AccountLinkFailureScenario( + error: AuthError.githubEmailConflict, + title: String(localized: "account_alert_github_email_conflict_title"), + message: String(localized: "account_alert_github_email_conflict_message") + ), AccountLinkFailureScenario( error: AccountTestError.failure, title: String(localized: "common_error_title"),