feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
@@ -0,0 +1,592 @@
// AccountCenterViewModelTests.swift
// OSGKeyboardTests
//
// Pure invitation parsing and account coordinator state-machine coverage.
@testable import OSGKeyboard
import XCTest
final class AccountCenterViewModelTests: XCTestCase {
private let validCode = "Abcdefghij_1234567890-"
func testUniversalLinkAcceptsCanonicalInvitation() {
let url = URL(string: "https://osglab.com/i/\(validCode)?source=share")!
XCTAssertEqual(ReferralUniversalLink.code(from: url), validCode)
}
func testUniversalLinkRejectsUntrustedOrMalformedURLs() {
let invalidURLs = [
"http://osglab.com/i/\(validCode)",
"https://example.com/i/\(validCode)",
"https://osglab.com/invite/\(validCode)",
"https://osglab.com/i/\(validCode)/extra",
"https://osglab.com/i/short",
"https://osglab.com/i/Abcdefghij%2F1234567890-"
]
for rawURL in invalidURLs {
XCTAssertNil(
ReferralUniversalLink.code(from: URL(string: rawURL)!),
"Unexpectedly accepted \(rawURL)"
)
}
}
func testReferralSummaryCountsEveryServerStatus() {
let referrals = [
makeReferral(status: .pending),
makeReferral(status: .pending),
makeReferral(status: .rewarded),
makeReferral(status: .ineligible)
]
XCTAssertEqual(
AccountReferralSummary(referrals: referrals),
AccountReferralSummary(pending: 2, rewarded: 1, ineligible: 1)
)
}
@MainActor
func testSignedOutInvitationIsPersistedWithoutRedeeming() async {
let service = AccountServiceSpy(restoredSession: nil)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let handled = coordinator.handleIncomingURL(
URL(string: "https://osglab.com/i/\(validCode)")!
)
XCTAssertTrue(handled)
XCTAssertEqual(store.code, validCode)
XCTAssertEqual(coordinator.pendingReferralCode, validCode)
XCTAssertTrue(coordinator.consumeAccountCenterPresentation())
XCTAssertFalse(coordinator.consumeAccountCenterPresentation())
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [])
}
@MainActor
func testRestoredSessionRedeemsPendingCodeThenLoadsSnapshot() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let snapshot = makeSnapshot(account: account)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: snapshot
)
let store = InMemoryPendingReferralStore(code: validCode)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
XCTAssertEqual(coordinator.snapshotPhase, .loaded(snapshot))
XCTAssertNil(store.code)
XCTAssertNil(coordinator.pendingReferralCode)
let redeemedCodes = await service.redeemedCodes()
let loadCount = await service.loadCount()
XCTAssertEqual(redeemedCodes, [validCode])
XCTAssertEqual(loadCount, 1)
}
@MainActor
func testRestoreReplayCallsServiceOnlyOnce() async {
let service = AccountServiceSpy(restoredSession: nil)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.restoreIfNeeded()
let restoreCount = await service.restoreCount()
XCTAssertEqual(restoreCount, 1)
}
@MainActor
func testFreshAccountSnapshotDoesNotReloadWhenEnteringAccountPages() 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)
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
now: { clock.now }
)
await coordinator.restoreIfNeeded()
await coordinator.refreshAccountData()
await coordinator.refreshAccountData()
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 1)
XCTAssertEqual(coordinator.lastAccountRefreshAt, clock.now)
}
@MainActor
func testStaleAccountSnapshotReloadsForEveryPage() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let original = makeSnapshot(account: account)
let latestCredits = AccountCreditSummary(balance: 750, usedCredits: 250)
let latest = AccountCenterSnapshot(
account: account,
credits: latestCredits,
referralProfile: AccountReferralProfile(
code: "LatestCode_1234567890",
boundCode: nil
),
referrals: [
AccountReferral(
id: UUID(),
status: .rewarded,
createdAtEpochSeconds: 1_700_000_100,
rewardCredits: 1_000
)
]
)
let clock = MutableAccountClock(now: Date(timeIntervalSince1970: 1_000))
let service = AccountServiceSpy(
restoredSession: account,
snapshot: original,
refreshedSnapshot: latest
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
now: { clock.now }
)
await coordinator.restoreIfNeeded()
clock.now = clock.now.addingTimeInterval(601)
await coordinator.refreshAccountData()
XCTAssertEqual(coordinator.snapshotPhase, .loaded(latest))
XCTAssertEqual(coordinator.lastAccountRefreshAt, clock.now)
let loadCount = await service.loadCount()
let receivedCachedSnapshot = await service.lastReceivedCachedSnapshot()
XCTAssertEqual(loadCount, 2)
XCTAssertEqual(receivedCachedSnapshot, original)
}
@MainActor
func testForcedRefreshBypassesFreshnessWindow() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let original = makeSnapshot(account: account)
let latest = AccountCenterSnapshot(
account: account,
credits: AccountCreditSummary(balance: 500, usedCredits: 500),
referralProfile: original.referralProfile,
referrals: original.referrals
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: original,
refreshedSnapshot: latest
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.refreshAccountData(force: true)
XCTAssertEqual(coordinator.snapshotPhase, .loaded(latest))
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 2)
}
@MainActor
func testConcurrentStaleAccountChecksShareOneRequest() 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)
async let first: Void = coordinator.refreshAccountData()
async let second: Void = coordinator.refreshAccountData()
_ = await (first, second)
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 2)
}
@MainActor
func testFailedAccountRefreshKeepsLastSuccessfulSnapshotAndTime() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let snapshot = makeSnapshot(account: account)
let initialRefreshAt = Date(timeIntervalSince1970: 1_000)
let clock = MutableAccountClock(now: initialRefreshAt)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: snapshot,
shouldFailAccountRefresh: true
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
now: { clock.now }
)
await coordinator.restoreIfNeeded()
clock.now = clock.now.addingTimeInterval(601)
await coordinator.refreshAccountData()
XCTAssertEqual(coordinator.snapshotPhase, .loaded(snapshot))
XCTAssertEqual(coordinator.lastAccountRefreshAt, initialRefreshAt)
XCTAssertEqual(
coordinator.accountRefreshErrorKey,
"account.error.load"
)
}
@MainActor
func testFailedRedemptionKeepsCodeForRetry() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
shouldFailRedemption: true
)
let store = InMemoryPendingReferralStore(code: validCode)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
XCTAssertEqual(store.code, validCode)
XCTAssertEqual(coordinator.pendingReferralCode, validCode)
XCTAssertEqual(coordinator.operationErrorKey, "account.error.redeemReferral")
}
@MainActor
func testSignOutClearsRemoteAccountStateOnly() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
shouldFailRedemption: true
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.signOut()
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(coordinator.snapshotPhase, .idle)
let signOutCount = await service.signOutCount()
XCTAssertEqual(signOutCount, 1)
}
@MainActor
func testConcurrentSignOutRequestsCollapseToOneMutation() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
signOutDelayNanoseconds: 20_000_000
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
let first = Task { await coordinator.signOut() }
while coordinator.operation != .signingOut {
await Task.yield()
}
let second = Task { await coordinator.signOut() }
await first.value
await second.value
let signOutCount = await service.signOutCount()
XCTAssertEqual(signOutCount, 1)
}
@MainActor
func testSuccessfulDeletionClearsAccountSnapshotAndPendingReferral() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000,
displayName: "Rocky"
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let pending = InMemoryPendingReferralStore(code: "ValidInvite_1234567890")
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: pending
)
await coordinator.restoreIfNeeded()
await coordinator.deleteAccount(
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
)
)
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(coordinator.snapshotPhase, .idle)
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertNil(pending.code)
let deleteCount = await service.deleteCount()
XCTAssertEqual(deleteCount, 1)
}
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
AccountReferral(
id: UUID(),
status: status,
createdAtEpochSeconds: nil,
rewardCredits: nil
)
}
private func makeSnapshot(account: AccountSession) -> AccountCenterSnapshot {
AccountCenterSnapshot(
account: account,
credits: AccountCreditSummary(
balance: 9_223_372_036_854_775_000,
usedCredits: 1_234
),
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
referrals: []
)
}
}
@MainActor
private final class InMemoryPendingReferralStore: PendingReferralCodeStoring {
private(set) var code: String?
init(code: String? = nil) {
self.code = code
}
func save(_ code: String) {
self.code = code
}
func clear() {
code = nil
}
}
@MainActor
private final class MutableAccountClock {
var now: Date
init(now: Date) {
self.now = now
}
}
private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing {
private let restored: AccountSession?
private let centerSnapshot: AccountCenterSnapshot?
private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool
private let shouldFailAccountRefresh: Bool
private let signOutDelayNanoseconds: UInt64
private let accountLoadDelayNanoseconds: UInt64
private var redeemed: [String] = []
private var centerLoadCount = 0
private var logoutCount = 0
private var accountDeleteCount = 0
private var sessionRestoreCount = 0
private var receivedCachedSnapshot: AccountCenterSnapshot?
init(
restoredSession: AccountSession?,
snapshot: AccountCenterSnapshot? = nil,
refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false,
shouldFailAccountRefresh: Bool = false,
signOutDelayNanoseconds: UInt64 = 0,
accountLoadDelayNanoseconds: UInt64 = 0
) {
restored = restoredSession
centerSnapshot = snapshot
self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption
self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.signOutDelayNanoseconds = signOutDelayNanoseconds
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
}
func restoreSession() async throws -> AccountSession? {
sessionRestoreCount += 1
return restored
}
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
guard let restored else { throw AccountIntegrationError.unavailable }
return restored
}
func signOut() async throws {
if signOutDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: signOutDelayNanoseconds)
}
logoutCount += 1
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
accountDeleteCount += 1
}
func loadAccountCenter() async throws -> AccountCenterSnapshot {
try await loadAccountCenter(cachedSnapshot: nil)
}
func loadAccountCenter(
cachedSnapshot: AccountCenterSnapshot?
) async throws -> AccountCenterSnapshot {
receivedCachedSnapshot = cachedSnapshot
centerLoadCount += 1
if centerLoadCount > 1, accountLoadDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: accountLoadDelayNanoseconds)
}
if centerLoadCount > 1, shouldFailAccountRefresh {
throw AccountServiceSpyError.failed
}
if centerLoadCount > 1, let refreshedSnapshot {
return refreshedSnapshot
}
guard let centerSnapshot else { throw AccountIntegrationError.unavailable }
return centerSnapshot
}
func createReferralCode() async throws -> String {
"CreatedCode_1234567890"
}
func redeemReferral(code: String) async throws {
if shouldFailRedemption {
throw AccountServiceSpyError.failed
}
redeemed.append(code)
}
func redeemedCodes() -> [String] {
redeemed
}
func loadCount() -> Int {
centerLoadCount
}
func lastReceivedCachedSnapshot() -> AccountCenterSnapshot? {
receivedCachedSnapshot
}
func signOutCount() -> Int {
logoutCount
}
func restoreCount() -> Int {
sessionRestoreCount
}
func deleteCount() -> Int {
accountDeleteCount
}
}
private enum AccountServiceSpyError: Error, Sendable {
case failed
}