feat(app): add flow diagnostics and refine interface
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
This commit is contained in:
Rocky
2026-08-26 14:40:06 +08:00
parent 1ee032bdb9
commit 39002336c0
78 changed files with 2737 additions and 735 deletions
@@ -3,6 +3,7 @@
//
// Pure invitation parsing and account coordinator state-machine coverage.
import AuthenticationServices
@testable import OSGKeyboard
import XCTest
@@ -184,7 +185,8 @@ final class AccountCenterViewModelTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
}
@@ -341,6 +343,40 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(loadCount, 2)
}
@MainActor
func testForcedRefreshQueuedDuringInFlightRefreshRunsAfterward() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let clock = MutableAccountClock(now: Date(timeIntervalSince1970: 1_000))
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
accountLoadDelayNanoseconds: 20_000_000
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
now: { clock.now }
)
await coordinator.restoreIfNeeded()
clock.now = clock.now.addingTimeInterval(601)
let staleRefresh = Task { await coordinator.refreshAccountData() }
while !coordinator.isRefreshingAccountData {
await Task.yield()
}
await coordinator.refreshAccountData(force: true)
await staleRefresh.value
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 3)
}
@MainActor
func testFailedAccountRefreshKeepsLastSuccessfulSnapshotAndTime() async {
let account = AccountSession(
@@ -488,7 +524,8 @@ final class AccountCenterViewModelTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
@@ -570,6 +607,128 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(signOutCount, 1)
}
@MainActor
func testMissingAppleIdentifierRequiresOneTimeReauthentication() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
appleCredentialState: .reauthenticationRequired
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(
coordinator.operationErrorKey,
"account.error.appleReauthenticationRequired"
)
}
@MainActor
func testRevocationSignOutFailureKeepsSessionForRetry() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
shouldFailSignOut: true,
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
XCTAssertEqual(coordinator.operationErrorKey, "account.error.signOut")
}
@MainActor
func testCredentialRevocationNotificationClearsSignedInState() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let center = NotificationCenter()
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
notificationCenter: center
)
await coordinator.restoreIfNeeded()
center.post(
name: ASAuthorizationAppleIDProvider.credentialRevokedNotification,
object: nil
)
for _ in 0..<100 where coordinator.isSignedIn {
await Task.yield()
}
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(coordinator.operationErrorKey, "account.error.sessionExpired")
}
@MainActor
func testRevocationPreventsInFlightProfileUpdateFromRestoringSession() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000,
displayName: "Before"
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
profileUpdateDelayNanoseconds: 20_000_000,
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
let update = Task { await coordinator.updateDisplayName("After") }
while coordinator.operation != .updatingProfile {
await Task.yield()
}
await coordinator.validateAppleCredentialState()
let didUpdate = await update.value
XCTAssertFalse(didUpdate)
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
}
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
AccountReferral(
id: UUID(),
@@ -642,9 +801,11 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool
private let shouldFailAccountRefresh: Bool
private let shouldFailSignOut: Bool
private let signOutDelayNanoseconds: UInt64
private let signInDelayNanoseconds: UInt64
private let accountLoadDelayNanoseconds: UInt64
private let profileUpdateDelayNanoseconds: UInt64
private var remainingRestoreFailures: Int
private let storedAppleCredentialState: AccountAppleCredentialState
private var redeemed: [String] = []
@@ -662,10 +823,12 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false,
shouldFailAccountRefresh: Bool = false,
shouldFailSignOut: Bool = false,
signOutDelayNanoseconds: UInt64 = 0,
signInDelayNanoseconds: UInt64 = 0,
restoreFailureCount: Int = 0,
accountLoadDelayNanoseconds: UInt64 = 0,
profileUpdateDelayNanoseconds: UInt64 = 0,
appleCredentialState: AccountAppleCredentialState = .unknown
) {
restored = restoredSession
@@ -674,10 +837,12 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption
self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.shouldFailSignOut = shouldFailSignOut
self.signOutDelayNanoseconds = signOutDelayNanoseconds
self.signInDelayNanoseconds = signInDelayNanoseconds
remainingRestoreFailures = restoreFailureCount
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
self.profileUpdateDelayNanoseconds = profileUpdateDelayNanoseconds
self.storedAppleCredentialState = appleCredentialState
}
@@ -703,6 +868,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
try await Task.sleep(nanoseconds: signOutDelayNanoseconds)
}
logoutCount += 1
if shouldFailSignOut {
throw AccountServiceSpyError.failed
}
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
@@ -739,6 +907,20 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
return centerSnapshot
}
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
if profileUpdateDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: profileUpdateDelayNanoseconds)
}
guard let signedInAccount else {
throw AccountIntegrationError.unavailable
}
return AccountSession(
accountID: signedInAccount.accountID,
createdAtEpochSeconds: signedInAccount.createdAtEpochSeconds,
displayName: displayName
)
}
func redeemReferral(code: String) async throws {
if shouldFailRedemption {
throw AccountServiceSpyError.failed