493 lines
20 KiB
Swift
493 lines
20 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])
|
|
}
|
|
|
|
func testSuspendedDatabaseDropsRecordsUntilExplicitResume() async throws {
|
|
let repository = AnalyticsRepository(
|
|
configuration: AnalyticsRepositoryConfiguration(
|
|
databaseURL: try analyticsTemporaryDatabaseURL()
|
|
),
|
|
clock: AnalyticsTestWallClock(),
|
|
uuidGenerator: AnalyticsTestUUIDGenerator()
|
|
)
|
|
await repository.record(
|
|
eventType: .keyboardActivated,
|
|
context: analyticsTestContext
|
|
)
|
|
|
|
await repository.suspendDatabaseAccess()
|
|
await repository.record(
|
|
eventType: .keyboardActivated,
|
|
context: analyticsTestContext
|
|
)
|
|
let suspendedSnapshot = await repository.debugSnapshot()
|
|
XCTAssertFalse(suspendedSnapshot.isAvailable)
|
|
|
|
await repository.resumeDatabaseAccess()
|
|
await repository.record(
|
|
eventType: .keyboardActivated,
|
|
context: analyticsTestContext
|
|
)
|
|
let resumedSnapshot = await repository.debugSnapshot()
|
|
XCTAssertTrue(resumedSnapshot.isAvailable)
|
|
XCTAssertEqual(resumedSnapshot.pendingEvents.count, 2)
|
|
}
|
|
}
|
|
|
|
private extension Array {
|
|
var single: Element? {
|
|
count == 1 ? first : nil
|
|
}
|
|
}
|