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.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// AnalyticsUploadCoordinatorTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// HTTP outcome, authentication, retry, and poison-event isolation coverage.
|
||||
|
||||
import Foundation
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class AnalyticsUploadCoordinatorTests: XCTestCase {
|
||||
private let endpoint = URL(string: "https://analytics.test/v1/events")!
|
||||
|
||||
func testExactAcceptedAndReplayedCountDeletesUsingOriginalPayloadBytes() async throws {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(clock: clock, eventCount: 2)
|
||||
let payloads = try await analyticsPendingPayloads(repository: repository)
|
||||
let expectedBody = analyticsRequestBody(payloads: payloads)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsSuccessResponse(accepted: 1, replayed: 1))
|
||||
])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||
let requests = await network.requests()
|
||||
let request = try XCTUnwrap(requests.first)
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
XCTAssertEqual(request.body, expectedBody)
|
||||
XCTAssertEqual(request.url, endpoint)
|
||||
XCTAssertEqual(request.headers["Content-Type"], "application/json")
|
||||
XCTAssertNil(request.headers["Authorization"])
|
||||
let topLevel = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual(Set(topLevel.keys), ["events"])
|
||||
XCTAssertEqual((topLevel["events"] as? [Any])?.count, 2)
|
||||
}
|
||||
|
||||
func testCountMismatchRetriesWithoutMutatingPayloadOrEventID() async throws {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(clock: clock, eventCount: 2)
|
||||
let originalPayloads = try await analyticsPendingPayloads(repository: repository)
|
||||
let originalIDs = try originalPayloads.map {
|
||||
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
|
||||
}
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsSuccessResponse(accepted: 1, replayed: 0))
|
||||
])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock,
|
||||
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertEqual(snapshot.pendingEvents.map(\.attemptCount), [1, 1])
|
||||
XCTAssertEqual(
|
||||
snapshot.pendingEvents.map(\.nextAttemptAt),
|
||||
[clock.now().addingTimeInterval(1), clock.now().addingTimeInterval(1)]
|
||||
)
|
||||
let retriedPayloads = try await analyticsPendingPayloads(repository: repository)
|
||||
XCTAssertEqual(retriedPayloads, originalPayloads)
|
||||
let retriedIDs = try retriedPayloads.map {
|
||||
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
|
||||
}
|
||||
XCTAssertEqual(retriedIDs, originalIDs)
|
||||
}
|
||||
|
||||
func testNetwork408429And5xxScheduleBoundedBackoff() async throws {
|
||||
let cases: [(AnalyticsQueueNetwork.Outcome, TimeInterval)] = [
|
||||
(.urlError(.notConnectedToInternet), 1),
|
||||
(.response(analyticsHTTPResponse(statusCode: 408)), 1),
|
||||
(
|
||||
.response(
|
||||
analyticsHTTPResponse(
|
||||
statusCode: 429,
|
||||
headers: ["rEtRy-AfTeR": "120"]
|
||||
)
|
||||
),
|
||||
120
|
||||
),
|
||||
(.response(analyticsHTTPResponse(statusCode: 503)), 1)
|
||||
]
|
||||
|
||||
for (index, testCase) in cases.enumerated() {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(
|
||||
clock: clock,
|
||||
eventCount: 1,
|
||||
uuidStart: 100 * (index + 1)
|
||||
)
|
||||
let originalPayloads = try await analyticsPendingPayloads(
|
||||
repository: repository
|
||||
)
|
||||
let originalPayload = try XCTUnwrap(originalPayloads.first)
|
||||
let network = AnalyticsQueueNetwork([testCase.0])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock,
|
||||
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
let diagnostic = try XCTUnwrap(snapshot.pendingEvents.first)
|
||||
XCTAssertEqual(diagnostic.attemptCount, 1, "case \(index)")
|
||||
XCTAssertEqual(
|
||||
diagnostic.nextAttemptAt,
|
||||
clock.now().addingTimeInterval(testCase.1),
|
||||
"case \(index)"
|
||||
)
|
||||
let storedPayload = await repository.debugPayloadBytes(rowID: diagnostic.rowID)
|
||||
XCTAssertEqual(storedPayload, originalPayload, "case \(index)")
|
||||
}
|
||||
}
|
||||
|
||||
func testUnauthorizedRefreshesOnceAndRetriesWithNewAuthorization() async throws {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(clock: clock, eventCount: 1)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsHTTPResponse(statusCode: 401)),
|
||||
.response(analyticsSuccessResponse(accepted: 1))
|
||||
])
|
||||
let bearer = AnalyticsTestBearerProvider(
|
||||
initialToken: "old-token",
|
||||
refreshedToken: "new-token"
|
||||
)
|
||||
let coordinator = AnalyticsUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: uploadConfiguration(),
|
||||
network: network,
|
||||
bearerProvider: bearer,
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||
random: AnalyticsTestRandomGenerator()
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
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 refreshInputs = await bearer.recordedRefreshInputs()
|
||||
XCTAssertEqual(refreshInputs.count, 1)
|
||||
XCTAssertEqual(refreshInputs[0], "old-token")
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||
}
|
||||
|
||||
func testSecondUnauthorizedDoesNotRefreshAgainAndSchedulesLongRetry() async throws {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(clock: clock, eventCount: 1)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsHTTPResponse(statusCode: 401)),
|
||||
.response(analyticsHTTPResponse(statusCode: 401))
|
||||
])
|
||||
let bearer = AnalyticsTestBearerProvider(
|
||||
initialToken: "old-token",
|
||||
refreshedToken: "new-token"
|
||||
)
|
||||
let configuration = uploadConfiguration(maximumBackoff: 600)
|
||||
let coordinator = AnalyticsUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: configuration,
|
||||
network: network,
|
||||
bearerProvider: bearer,
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||
random: AnalyticsTestRandomGenerator(.upperBound)
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
let refreshInputs = await bearer.recordedRefreshInputs()
|
||||
XCTAssertEqual(refreshInputs.count, 1)
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertEqual(snapshot.pendingEvents.first?.attemptCount, 1)
|
||||
XCTAssertEqual(
|
||||
snapshot.pendingEvents.first?.nextAttemptAt,
|
||||
clock.now().addingTimeInterval(600)
|
||||
)
|
||||
}
|
||||
|
||||
func testValidationStatusesRecursivelyIsolateOnlyPoisonEvent() async throws {
|
||||
for statusCode in [400, 409, 422] {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(
|
||||
clock: clock,
|
||||
eventCount: 4,
|
||||
uuidStart: statusCode
|
||||
)
|
||||
let events = try await analyticsDecodePendingEvents(repository: repository)
|
||||
let poisonID = try XCTUnwrap(events.dropFirst().first?.clientEventId)
|
||||
let network = AnalyticsPoisonNetwork(
|
||||
statusCode: statusCode,
|
||||
poisonID: poisonID
|
||||
)
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertTrue(snapshot.pendingEvents.isEmpty, "HTTP \(statusCode)")
|
||||
XCTAssertEqual(snapshot.quarantinedEvents.count, 1, "HTTP \(statusCode)")
|
||||
XCTAssertEqual(
|
||||
snapshot.quarantinedEvents.first?.reason,
|
||||
"http\(statusCode)"
|
||||
)
|
||||
let quarantinedRowID = try XCTUnwrap(
|
||||
snapshot.quarantinedEvents.first?.rowID
|
||||
)
|
||||
let quarantinedPayload = await repository.debugPayloadBytes(
|
||||
rowID: quarantinedRowID,
|
||||
quarantined: true
|
||||
)
|
||||
let quarantinedEvent = try JSONDecoder().decode(
|
||||
AnalyticsEvent.self,
|
||||
from: try XCTUnwrap(quarantinedPayload)
|
||||
)
|
||||
XCTAssertEqual(quarantinedEvent.clientEventId, poisonID)
|
||||
let requests = await network.requests()
|
||||
XCTAssertGreaterThan(requests.count, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testOtherPermanent4xxQuarantinesWholeBatchWithoutRetry() async throws {
|
||||
let clock = AnalyticsTestWallClock()
|
||||
let repository = try await makeRepository(clock: clock, eventCount: 3)
|
||||
let network = AnalyticsQueueNetwork([
|
||||
.response(analyticsHTTPResponse(statusCode: 403))
|
||||
])
|
||||
let coordinator = makeCoordinator(
|
||||
repository: repository,
|
||||
network: network,
|
||||
clock: clock
|
||||
)
|
||||
|
||||
await coordinator.uploadAvailableEvents()
|
||||
|
||||
let snapshot = await repository.debugSnapshot()
|
||||
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
|
||||
XCTAssertEqual(snapshot.quarantinedEvents.count, 3)
|
||||
XCTAssertEqual(Set(snapshot.quarantinedEvents.map(\.reason)), ["http403"])
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
private func makeRepository(
|
||||
clock: AnalyticsTestWallClock,
|
||||
eventCount: Int,
|
||||
uuidStart: Int = 1
|
||||
) async throws -> AnalyticsRepository {
|
||||
let repository = AnalyticsRepository(
|
||||
configuration: AnalyticsRepositoryConfiguration(
|
||||
databaseURL: try analyticsTemporaryDatabaseURL()
|
||||
),
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart)
|
||||
)
|
||||
await analyticsRecordKeyboardEvents(
|
||||
count: eventCount,
|
||||
repository: repository
|
||||
)
|
||||
return repository
|
||||
}
|
||||
|
||||
private func uploadConfiguration(
|
||||
maximumBackoff: TimeInterval = 600
|
||||
) -> AnalyticsUploadConfiguration {
|
||||
AnalyticsUploadConfiguration(
|
||||
endpoint: endpoint,
|
||||
maximumBackoff: maximumBackoff
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCoordinator(
|
||||
repository: AnalyticsRepository,
|
||||
network: some AnalyticsNetworking,
|
||||
clock: AnalyticsTestWallClock,
|
||||
random: AnalyticsTestRandomGenerator = AnalyticsTestRandomGenerator()
|
||||
) -> AnalyticsUploadCoordinator {
|
||||
AnalyticsUploadCoordinator(
|
||||
repository: repository,
|
||||
configuration: uploadConfiguration(),
|
||||
network: network,
|
||||
clock: clock,
|
||||
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
|
||||
random: random
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user