Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
// KeyboardUsageUploadCoordinatorTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Aggregate endpoint authentication, acknowledgement and retry policy coverage.
|
||||
|
||||
import Foundation
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardHostSupport
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class KeyboardUsageUploadCoordinatorTests: XCTestCase {
|
||||
private let endpoint = URL(
|
||||
string: "https://analytics.test/v1/analytics/keyboard-usage"
|
||||
)!
|
||||
|
||||
func testAcceptedAndReplayedDeleteOnlyAfterExactAcknowledgement() async throws {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(clock: clock)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 19),
|
||||
session: 1,
|
||||
repository: repository
|
||||
)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: 2,
|
||||
repository: repository
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(keyboardUsageSuccessResponse(accepted: 1, replayed: 1))
|
||||
])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertTrue(snapshot.pending.isEmpty)
|
||||
let requests = await network.requests()
|
||||
let request = try XCTUnwrap(requests.first)
|
||||
XCTAssertEqual(request.url, endpoint)
|
||||
XCTAssertNil(request.headers["Authorization"])
|
||||
let upload = try JSONDecoder().decode(
|
||||
KeyboardUsageUploadRequest.self,
|
||||
from: request.body
|
||||
)
|
||||
XCTAssertEqual(upload.installationId, analyticsTestUUID(900))
|
||||
XCTAssertEqual(upload.summaries.count, 2)
|
||||
XCTAssertEqual(
|
||||
Set(upload.summaries.map(\.summaryDate)),
|
||||
["2026-08-19", "2026-08-20"]
|
||||
)
|
||||
}
|
||||
|
||||
func testCurrentUTCDayIsNeverUploaded() async throws {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(clock: clock)
|
||||
await keyboardUsageRecord(
|
||||
.init(chinese: 1),
|
||||
sessionID: analyticsTestUUID(3),
|
||||
occurredAt: clock.now(),
|
||||
repository: repository
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let requests = await network.requests()
|
||||
XCTAssertTrue(requests.isEmpty)
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertEqual(snapshot.daily.first?.summaryDate, "2026-08-21")
|
||||
XCTAssertTrue(snapshot.pending.isEmpty)
|
||||
}
|
||||
|
||||
func testUnauthorizedRefreshesOnceAndRetriesSameSummary() async throws {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(clock: clock)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: 4,
|
||||
repository: repository
|
||||
)
|
||||
let originalSnapshot = await repository.debugSnapshot()
|
||||
let originalID = try XCTUnwrap(
|
||||
originalSnapshot.pending.first?.clientSummaryID
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsHTTPResponse(statusCode: 401)),
|
||||
.response(keyboardUsageSuccessResponse(accepted: 1))
|
||||
])
|
||||
let bearer = AnalyticsTestBearerProvider(
|
||||
initialToken: "old-token",
|
||||
refreshedToken: "new-token"
|
||||
)
|
||||
let coordinator = KeyboardUsageUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: uploadConfiguration(),
|
||||
network: network,
|
||||
bearerProvider: bearer,
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||
random: AnalyticsTestRandomGenerator()
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 2)
|
||||
XCTAssertEqual(requests[0].headers["Authorization"], "Bearer old-token")
|
||||
XCTAssertEqual(requests[1].headers["Authorization"], "Bearer new-token")
|
||||
XCTAssertEqual(requests[0].body, requests[1].body)
|
||||
let replayed = try JSONDecoder().decode(
|
||||
KeyboardUsageUploadRequest.self,
|
||||
from: requests[1].body
|
||||
)
|
||||
XCTAssertEqual(replayed.summaries.first?.clientSummaryId, originalID)
|
||||
let refreshInputs = await bearer.recordedRefreshInputs()
|
||||
XCTAssertEqual(refreshInputs, ["old-token"])
|
||||
}
|
||||
|
||||
func testAnonymousUploadOmitsAuthorizationWhenNoSessionExists() async throws {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(clock: clock)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: 5,
|
||||
repository: repository
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(keyboardUsageSuccessResponse(accepted: 1))
|
||||
])
|
||||
let bearer = AnalyticsTestBearerProvider(
|
||||
initialToken: nil,
|
||||
refreshedToken: nil
|
||||
)
|
||||
let coordinator = KeyboardUsageUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: uploadConfiguration(),
|
||||
network: network,
|
||||
bearerProvider: bearer,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let requests = await network.requests()
|
||||
let request = try XCTUnwrap(requests.first)
|
||||
XCTAssertNil(request.headers["Authorization"])
|
||||
}
|
||||
|
||||
func testKnownExpiredAccountSessionFallsBackToAnonymousToken() async throws {
|
||||
let store = InMemoryAccountSecurityStore(
|
||||
session: makeAccountSession(
|
||||
accessExpiry: 900,
|
||||
refreshExpiry: 950
|
||||
)
|
||||
)
|
||||
let transport = QueueAccountTransport([])
|
||||
let client = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
let provider = AccountAnalyticsBearerProvider(apiClient: client)
|
||||
|
||||
let token = try await provider.bearerToken()
|
||||
|
||||
XCTAssertNil(token)
|
||||
let storedSession = await store.session
|
||||
let requests = await transport.requests
|
||||
XCTAssertNil(storedSession)
|
||||
XCTAssertTrue(requests.isEmpty)
|
||||
}
|
||||
|
||||
func testValidationStatusesQuarantineWithoutInfiniteRetry() async throws {
|
||||
for statusCode in [400, 409, 422] {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(
|
||||
clock: clock,
|
||||
uuidStart: statusCode
|
||||
)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: statusCode,
|
||||
repository: repository
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsHTTPResponse(statusCode: statusCode))
|
||||
])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertTrue(snapshot.pending.isEmpty, "HTTP \(statusCode)")
|
||||
XCTAssertEqual(snapshot.quarantined.count, 1, "HTTP \(statusCode)")
|
||||
XCTAssertEqual(
|
||||
snapshot.quarantined.first?.statusCode,
|
||||
statusCode
|
||||
)
|
||||
XCTAssertNil(snapshot.quarantined.first?.clientSummaryID)
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testNetworkAndServerFailuresUseExponentialRetry() async throws {
|
||||
let outcomes: [AnalyticsQueueNetwork.Outcome] = [
|
||||
.urlError(.notConnectedToInternet),
|
||||
.response(analyticsHTTPResponse(statusCode: 503))
|
||||
]
|
||||
for (index, outcome) in outcomes.enumerated() {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(
|
||||
clock: clock,
|
||||
uuidStart: 100 + index
|
||||
)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: 10 + index,
|
||||
repository: repository
|
||||
)
|
||||
let originalSnapshot = await repository.debugSnapshot()
|
||||
let originalID = try XCTUnwrap(
|
||||
originalSnapshot.pending.first?.clientSummaryID
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([outcome])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock,
|
||||
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let retrySnapshot = await repository.debugSnapshot()
|
||||
let pending = try XCTUnwrap(retrySnapshot.pending.first)
|
||||
XCTAssertEqual(pending.attemptCount, 1)
|
||||
XCTAssertEqual(
|
||||
pending.nextAttemptAt,
|
||||
clock.now().addingTimeInterval(1)
|
||||
)
|
||||
XCTAssertEqual(pending.clientSummaryID, originalID)
|
||||
}
|
||||
}
|
||||
|
||||
func testResponseCountMismatchRetainsOutbox() async throws {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(clock: clock)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: 20,
|
||||
repository: repository
|
||||
)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(keyboardUsageSuccessResponse(accepted: 0, replayed: 0))
|
||||
])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock,
|
||||
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
|
||||
let pending = await repository.debugSnapshot().pending
|
||||
XCTAssertEqual(pending.count, 1)
|
||||
XCTAssertEqual(pending.first?.attemptCount, 1)
|
||||
}
|
||||
|
||||
func testCancellationReleasesSummaryLeaseWithoutRetry() async throws {
|
||||
let clock = AnalyticsTestWallClock(keyboardUsageDate(2026, 8, 21))
|
||||
let repository = makeRepository(clock: clock)
|
||||
await seedSummary(
|
||||
date: keyboardUsageDate(2026, 8, 20),
|
||||
session: 21,
|
||||
repository: repository
|
||||
)
|
||||
let network = CancellableKeyboardUsageNetwork()
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
let upload = Task {
|
||||
await coordinator.uploadAvailableSummaries()
|
||||
}
|
||||
for _ in 0..<100 {
|
||||
if await network.didStart() {
|
||||
break
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
let didStart = await network.didStart()
|
||||
XCTAssertTrue(didStart)
|
||||
|
||||
upload.cancel()
|
||||
await upload.value
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertEqual(snapshot.pending.first?.attemptCount, 0)
|
||||
let recovered = await repository.leaseBatch(
|
||||
ownerID: "after-cancellation",
|
||||
configuration: uploadConfiguration()
|
||||
)
|
||||
XCTAssertEqual(recovered?.summaries.count, 1)
|
||||
}
|
||||
|
||||
private func seedSummary(
|
||||
date: Date,
|
||||
session: Int,
|
||||
repository: KeyboardUsageRepository
|
||||
) async {
|
||||
await keyboardUsageRecord(
|
||||
.init(chinese: 1, english: 1, other: 1),
|
||||
sessionID: analyticsTestUUID(session),
|
||||
occurredAt: date,
|
||||
repository: repository
|
||||
)
|
||||
}
|
||||
|
||||
private func makeRepository(
|
||||
clock: AnalyticsTestWallClock,
|
||||
uuidStart: Int = 100
|
||||
) -> KeyboardUsageRepository {
|
||||
KeyboardUsageRepository(
|
||||
configuration: KeyboardUsageRepositoryConfiguration(
|
||||
databaseURL: try! keyboardUsageTemporaryDatabaseURL()
|
||||
),
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart)
|
||||
)
|
||||
}
|
||||
|
||||
private func uploadConfiguration() -> KeyboardUsageUploadConfiguration {
|
||||
KeyboardUsageUploadConfiguration(
|
||||
endpoint: endpoint,
|
||||
maximumBackoff: 600
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCoordinator(
|
||||
repository: KeyboardUsageRepository,
|
||||
network: some AnalyticsNetworking,
|
||||
clock: AnalyticsTestWallClock,
|
||||
random: AnalyticsTestRandomGenerator = AnalyticsTestRandomGenerator()
|
||||
) -> KeyboardUsageUploadCoordinator {
|
||||
KeyboardUsageUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: uploadConfiguration(),
|
||||
network: network,
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||
random: random
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private actor CancellableKeyboardUsageNetwork: AnalyticsNetworking {
|
||||
private var started = false
|
||||
|
||||
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
|
||||
_ = request
|
||||
started = true
|
||||
try await Task.sleep(for: .seconds(30))
|
||||
return keyboardUsageSuccessResponse(accepted: 1)
|
||||
}
|
||||
|
||||
func didStart() -> Bool {
|
||||
started
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user