498f407585
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.
593 lines
20 KiB
Swift
593 lines
20 KiB
Swift
// 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
|
|
}
|