Files
OSGKeyboard/OSGKeyboardTests/AnalyticsRepositoryTests.swift
T
Rocky 10bc457c72
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
feat(app): add resilient Flow recovery and privacy-safe analytics
Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
2026-08-20 20:22:20 +08:00

462 lines
19 KiB
Swift

// AnalyticsRepositoryTests.swift
// OSGKeyboardTests
//
// Durable identity, queue policy, retention, and cross-process lease coverage.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsRepositoryTests: XCTestCase {
func testFirstOpenMarkerAndEventAreCreatedExactlyOnceAcrossRestart() async throws {
let url = try analyticsTemporaryDatabaseURL()
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .referral
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .socialContent
)
let firstSnapshot = await repository.debugSnapshot()
XCTAssertTrue(firstSnapshot.firstOpenRecorded)
XCTAssertEqual(firstSnapshot.pendingEvents.map(\.eventType), [.firstOpen])
let decodedEvents = try await analyticsDecodePendingEvents(repository: repository)
let event = try XCTUnwrap(decodedEvents.first)
XCTAssertEqual(event.installationId, analyticsTestUUID(1))
XCTAssertEqual(event.clientEventId, analyticsTestUUID(2))
XCTAssertEqual(event.acquisitionChannel, .referral)
let restarted = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
)
await restarted.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .appStoreOrganic
)
let restartedSnapshot = await restarted.debugSnapshot()
XCTAssertEqual(restartedSnapshot.installationID, analyticsTestUUID(1))
XCTAssertEqual(restartedSnapshot.pendingEvents.map(\.eventType), [.firstOpen])
}
func testSessionWindowTracksThirtyMinutesOfInactivityPerSurface() async throws {
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
clock.advance(by: 29 * 60)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
clock.advance(by: 29 * 60)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
var snapshot = await repository.debugSnapshot()
XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.sessionStarted])
clock.advance(by: 30 * 60)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
snapshot = await repository.debugSnapshot()
XCTAssertEqual(
snapshot.pendingEvents.compactMap(\.eventType),
[.sessionStarted, .sessionStarted]
)
}
func testDisableClearsQueuePersistsAcrossRestartAndReenableRotatesIdentity() async throws {
let url = try analyticsTemporaryDatabaseURL()
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .unknown
)
let originalID = await repository.debugSnapshot().installationID
await repository.setEnabled(false)
var snapshot = await repository.debugSnapshot()
XCTAssertFalse(snapshot.enabled)
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
let restarted = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
)
let remainsDisabled = await restarted.isEnabled()
XCTAssertFalse(remainsDisabled)
await restarted.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
let disabledSnapshot = await restarted.debugSnapshot()
XCTAssertTrue(disabledSnapshot.pendingEvents.isEmpty)
await restarted.setEnabled(true)
await restarted.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .referral
)
await restarted.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
snapshot = await restarted.debugSnapshot()
XCTAssertTrue(snapshot.enabled)
XCTAssertNotEqual(snapshot.installationID, originalID)
XCTAssertTrue(snapshot.firstOpenRecorded)
XCTAssertEqual(snapshot.pendingEvents.map(\.eventType), [.keyboardActivated])
}
func testAccountObservationSwitchAndDeletionRotateWithoutRepeatingFirstOpen() async throws {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .appStoreOrganic
)
let installationBeforeAccount = await repository.debugSnapshot().installationID
let first = await repository.observeAccount(stableIdentifier: "account-a")
guard case .firstAccount = first else {
return XCTFail("Expected firstAccount, got \(first)")
}
let installationAfterFirstAccount = await repository.debugSnapshot().installationID
XCTAssertEqual(installationAfterFirstAccount, installationBeforeAccount)
let unchanged = await repository.observeAccount(stableIdentifier: "account-a")
guard case .unchanged = unchanged else {
return XCTFail("Expected unchanged, got \(unchanged)")
}
await repository.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
let switched = await repository.observeAccount(stableIdentifier: "account-b")
guard case .switchedAccount = switched else {
return XCTFail("Expected switchedAccount, got \(switched)")
}
let afterSwitch = await repository.debugSnapshot()
XCTAssertNotEqual(afterSwitch.installationID, installationBeforeAccount)
XCTAssertTrue(afterSwitch.pendingEvents.isEmpty)
XCTAssertTrue(afterSwitch.firstOpenRecorded)
await repository.record(
eventType: .purchaseViewed,
context: analyticsTestContext
)
let switchedID = afterSwitch.installationID
await repository.handleAccountDeletion()
let afterDeletion = await repository.debugSnapshot()
XCTAssertNotEqual(afterDeletion.installationID, switchedID)
XCTAssertTrue(afterDeletion.pendingEvents.isEmpty)
XCTAssertTrue(afterDeletion.firstOpenRecorded)
let firstAfterDeletion = await repository.observeAccount(stableIdentifier: "account-b")
guard case .firstAccount = firstAfterDeletion else {
return XCTFail("Expected firstAccount after deletion, got \(firstAfterDeletion)")
}
}
func testRetryPreservesCanonicalPayloadAndClientEventID() async throws {
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await analyticsRecordKeyboardEvents(count: 1, repository: repository)
let snapshot = await repository.debugSnapshot()
let rowID = try XCTUnwrap(snapshot.pendingEvents.first?.rowID)
let storedOriginalPayload = await repository.debugPayloadBytes(rowID: rowID)
let originalPayload = try XCTUnwrap(storedOriginalPayload)
let originalEvent = try JSONDecoder().decode(
AnalyticsEvent.self,
from: originalPayload
)
let upload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!
)
let leasedFirstBatch = await repository.leaseBatch(
ownerID: "owner",
configuration: upload
)
let firstLease = try XCTUnwrap(leasedFirstBatch)
await repository.scheduleRetry(
events: firstLease.events,
leaseID: firstLease.leaseID,
delay: 10
)
let storedRetriedPayload = await repository.debugPayloadBytes(rowID: rowID)
let retriedPayload = try XCTUnwrap(storedRetriedPayload)
XCTAssertEqual(retriedPayload, originalPayload)
let retrySnapshot = await repository.debugSnapshot()
let retryDiagnostic = try XCTUnwrap(retrySnapshot.pendingEvents.first)
XCTAssertEqual(retryDiagnostic.attemptCount, 1)
XCTAssertEqual(retryDiagnostic.nextAttemptAt, clock.now().addingTimeInterval(10))
clock.advance(by: 10)
let leasedSecondBatch = await repository.leaseBatch(
ownerID: "owner",
configuration: upload
)
let secondLease = try XCTUnwrap(leasedSecondBatch)
XCTAssertEqual(secondLease.events.first?.payload, originalPayload)
let retriedEvent = try JSONDecoder().decode(
AnalyticsEvent.self,
from: try XCTUnwrap(secondLease.events.first?.payload)
)
XCTAssertEqual(retriedEvent.clientEventId, originalEvent.clientEventId)
}
func testConcurrentRepositoriesEnforceGlobalLeaseAndRecoverExpiredEventLease() async throws {
let url = try analyticsTemporaryDatabaseURL()
let clock = AnalyticsTestWallClock()
let firstRepository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
let secondRepository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(databaseURL: url),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 100)
)
await analyticsRecordKeyboardEvents(count: 1, repository: firstRepository)
let upload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
globalLeaseDuration: 60,
eventLeaseDuration: 60
)
async let first = firstRepository.leaseBatch(
ownerID: "first-owner",
configuration: upload
)
async let second = secondRepository.leaseBatch(
ownerID: "second-owner",
configuration: upload
)
let simultaneous = await (first, second)
XCTAssertEqual([simultaneous.0, simultaneous.1].compactMap { $0 }.count, 1)
clock.advance(by: 61)
let recovered = await secondRepository.leaseBatch(
ownerID: "recovery-owner",
configuration: upload
)
XCTAssertEqual(recovered?.events.count, 1)
}
func testCrashAfterServerAcceptanceBeforeDeleteReplaysOriginalBytes() async throws {
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await analyticsRecordKeyboardEvents(count: 1, repository: repository)
let configuration = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
globalLeaseDuration: 60,
eventLeaseDuration: 60
)
let crashedLease = await repository.leaseBatch(
ownerID: "crashed-owner",
configuration: configuration
)
let acceptedButNotDeleted = try XCTUnwrap(crashedLease)
clock.advance(by: 61)
let network = AnalyticsQueueNetwork([
.response(analyticsSuccessResponse(accepted: 0, replayed: 1))
])
let coordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: configuration,
network: network,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 500),
random: AnalyticsTestRandomGenerator()
)
await coordinator.uploadAvailableEvents()
let requests = await network.requests()
XCTAssertEqual(requests.single?.body, acceptedButNotDeleted.body)
let completedSnapshot = await repository.debugSnapshot()
XCTAssertTrue(completedSnapshot.pendingEvents.isEmpty)
}
func testBatchHonorsFiftyEventAndDynamicBodyByteLimits() async throws {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await analyticsRecordKeyboardEvents(count: 55, repository: repository)
let defaultUpload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
maximumBatchCount: 100,
maximumBodyBytes: 100_000
)
let countLease = await repository.leaseBatch(
ownerID: "count",
configuration: defaultUpload
)
let countLimited = try XCTUnwrap(countLease)
XCTAssertEqual(countLimited.events.count, 50)
XCTAssertLessThanOrEqual(countLimited.body.count, 60 * 1_024)
await repository.releaseEvents(
countLimited.events,
leaseID: countLimited.leaseID
)
await repository.releaseGlobalLease(ownerID: "count")
let byteUpload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!,
maximumBatchCount: 50,
maximumBodyBytes: 1_024
)
let byteLease = await repository.leaseBatch(
ownerID: "bytes",
configuration: byteUpload
)
let byteLimited = try XCTUnwrap(byteLease)
XCTAssertFalse(byteLimited.events.isEmpty)
XCTAssertLessThan(byteLimited.events.count, 50)
XCTAssertLessThanOrEqual(byteLimited.body.count, 1_024)
let snapshot = await repository.debugSnapshot()
let selectedIDs = Set(byteLimited.events.map(\.rowID))
let nextDiagnostic = try XCTUnwrap(
snapshot.pendingEvents.first { !selectedIDs.contains($0.rowID) }
)
let storedNextPayload = await repository.debugPayloadBytes(
rowID: nextDiagnostic.rowID
)
let nextPayload = try XCTUnwrap(storedNextPayload)
XCTAssertGreaterThan(byteLimited.body.count + 1 + nextPayload.count, 1_024)
}
func testRetentionExpiresAfterThirtyFourDaysAndToleratesClockRollback() async throws {
let retention = 34 * 24 * 60 * 60.0
let clock = AnalyticsTestWallClock()
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL(),
eventRetention: retention
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
clock.advance(by: retention)
await repository.record(
eventType: .purchaseViewed,
context: analyticsTestContext
)
let boundarySnapshot = await repository.debugSnapshot()
XCTAssertEqual(boundarySnapshot.pendingEvents.count, 2)
clock.advance(by: 0.001)
await repository.record(
eventType: .purchaseStarted,
context: analyticsTestContext
)
var events = try await analyticsDecodePendingEvents(repository: repository)
XCTAssertEqual(Set(events.map(\.eventType)), [.purchaseViewed, .purchaseStarted])
let future = clock.now().addingTimeInterval(24 * 60 * 60)
clock.set(future)
await repository.record(
eventType: .referralShared,
context: analyticsTestContext
)
clock.set(future.addingTimeInterval(-7 * 24 * 60 * 60))
let upload = AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/events")!
)
let batch = await repository.leaseBatch(ownerID: "rollback", configuration: upload)
events = try batch?.events.map {
try JSONDecoder().decode(AnalyticsEvent.self, from: $0.payload)
} ?? []
XCTAssertTrue(events.contains(where: { $0.eventType == .referralShared }))
}
func testCapacityEvictsSessionAndKeyboardBeforeHighValueEvents() async throws {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL(),
maximumEventCount: 2
),
clock: AnalyticsTestWallClock(),
uuidGenerator: AnalyticsTestUUIDGenerator()
)
await repository.prepare(
using: analyticsTestContext,
firstOpenAcquisitionChannel: .unknown
)
await repository.recordSessionIfNeeded(context: analyticsTestContext)
await repository.record(
eventType: .keyboardActivated,
context: analyticsTestContext
)
var eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType)
XCTAssertEqual(Set(eventTypes), [.firstOpen, .keyboardActivated])
await repository.record(
eventType: .aiFeatureSucceeded,
context: analyticsTestContext,
dimensions: AnalyticsEventDimensions(
feature: .polish,
executionMode: .managed,
durationBucket: .oneToThreeSeconds
)
)
eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType)
XCTAssertEqual(Set(eventTypes), [.firstOpen, .aiFeatureSucceeded])
}
}
private extension Array {
var single: Element? {
count == 1 ? first : nil
}
}