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,165 @@
|
||||
// AnalyticsModelTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Wire-contract and privacy invariants for the allowlisted analytics schema.
|
||||
|
||||
import Foundation
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class AnalyticsModelTests: XCTestCase {
|
||||
func testEventEncodingContainsOnlyAllowlistedKeysAndNoFreeTextContainer() throws {
|
||||
let event = try AnalyticsEvent(
|
||||
installationId: analyticsTestUUID(1),
|
||||
clientEventId: analyticsTestUUID(2),
|
||||
eventType: .aiFeatureFailed,
|
||||
occurredAt: Date(timeIntervalSince1970: 1_700_000_000.125),
|
||||
surface: .keyboard,
|
||||
appVersion: "2.0.0",
|
||||
osVersion: "26.0",
|
||||
feature: .polish,
|
||||
executionMode: .managed,
|
||||
failureCategory: .network,
|
||||
durationBucket: .oneToThreeSeconds
|
||||
)
|
||||
|
||||
let data = try JSONEncoder().encode(event)
|
||||
let object = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
Set(object.keys),
|
||||
[
|
||||
"installationId",
|
||||
"clientEventId",
|
||||
"eventType",
|
||||
"occurredAt",
|
||||
"surface",
|
||||
"appVersion",
|
||||
"osVersion",
|
||||
"feature",
|
||||
"executionMode",
|
||||
"failureCategory",
|
||||
"durationBucket"
|
||||
]
|
||||
)
|
||||
XCTAssertFalse(object.keys.contains("properties"))
|
||||
XCTAssertFalse(object.keys.contains("text"))
|
||||
XCTAssertEqual(
|
||||
Set(Mirror(reflecting: event).children.compactMap(\.label)),
|
||||
Set(object.keys).union(["acquisitionChannel"])
|
||||
)
|
||||
}
|
||||
|
||||
func testUnknownFieldsAreRejectedAtEveryWireEnvelope() throws {
|
||||
let event = try AnalyticsEvent(
|
||||
installationId: analyticsTestUUID(1),
|
||||
clientEventId: analyticsTestUUID(2),
|
||||
eventType: .sessionStarted,
|
||||
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
surface: .app,
|
||||
appVersion: "2.0.0",
|
||||
osVersion: "26.0"
|
||||
)
|
||||
var eventObject = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: JSONEncoder().encode(event))
|
||||
as? [String: Any]
|
||||
)
|
||||
eventObject["userText"] = "must be rejected"
|
||||
assertUnknownField(
|
||||
try JSONSerialization.data(withJSONObject: eventObject),
|
||||
as: AnalyticsEvent.self,
|
||||
expected: "userText"
|
||||
)
|
||||
|
||||
let request = Data(#"{"events":[],"properties":{"secret":"value"}}"#.utf8)
|
||||
assertUnknownField(
|
||||
request,
|
||||
as: AnalyticsUploadRequest.self,
|
||||
expected: "properties"
|
||||
)
|
||||
|
||||
let response = Data(#"{"accepted":1,"replayed":0,"debug":"secret"}"#.utf8)
|
||||
assertUnknownField(
|
||||
response,
|
||||
as: AnalyticsUploadResponse.self,
|
||||
expected: "debug"
|
||||
)
|
||||
}
|
||||
|
||||
func testEnvironmentFiltersAndTruncatesVersionsToSafeBound() {
|
||||
let environment = AnalyticsEnvironment(
|
||||
appVersion: String(repeating: "a", count: 40) + "/private",
|
||||
osVersion: "26.0 (Build 23A)/用户"
|
||||
)
|
||||
|
||||
XCTAssertEqual(environment.appVersion, String(repeating: "a", count: 32))
|
||||
XCTAssertEqual(environment.appVersion.utf8.count, 32)
|
||||
XCTAssertEqual(environment.osVersion, "26.0Build23A")
|
||||
|
||||
let empty = AnalyticsEnvironment(appVersion: "用户 /", osVersion: "")
|
||||
XCTAssertEqual(empty.appVersion, "unknown")
|
||||
XCTAssertEqual(empty.osVersion, "unknown")
|
||||
}
|
||||
|
||||
func testPurchaseCancelledRequiresCancelledFailureCategory() throws {
|
||||
XCTAssertNoThrow(
|
||||
try AnalyticsEvent(
|
||||
installationId: analyticsTestUUID(1),
|
||||
clientEventId: analyticsTestUUID(2),
|
||||
eventType: .purchaseCancelled,
|
||||
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
surface: .app,
|
||||
appVersion: "2.0.0",
|
||||
osVersion: "26.0",
|
||||
failureCategory: .cancelled
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertThrowsError(
|
||||
try AnalyticsEvent(
|
||||
installationId: analyticsTestUUID(1),
|
||||
clientEventId: analyticsTestUUID(3),
|
||||
eventType: .purchaseCancelled,
|
||||
occurredAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
surface: .app,
|
||||
appVersion: "2.0.0",
|
||||
osVersion: "26.0",
|
||||
failureCategory: .network
|
||||
)
|
||||
) { error in
|
||||
guard case AnalyticsModelError.invalidDimensions(.purchaseCancelled) = error else {
|
||||
return XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testDurationBucketBoundariesAreStable() {
|
||||
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 999_999_999), .lessThanOneSecond)
|
||||
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 1_000_000_000), .oneToThreeSeconds)
|
||||
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 2_999_999_999), .oneToThreeSeconds)
|
||||
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 3_000_000_000), .threeToTenSeconds)
|
||||
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 10_000_000_000), .tenToThirtySeconds)
|
||||
XCTAssertEqual(AnalyticsDurationBucket(elapsedNanoseconds: 30_000_000_000), .thirtySecondsOrMore)
|
||||
}
|
||||
|
||||
private func assertUnknownField<T: Decodable>(
|
||||
_ data: Data,
|
||||
as type: T.Type,
|
||||
expected: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
XCTAssertThrowsError(
|
||||
try JSONDecoder().decode(type, from: data),
|
||||
file: file,
|
||||
line: line
|
||||
) { error in
|
||||
guard case AnalyticsModelError.unknownField(let field) = error else {
|
||||
return XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||||
}
|
||||
XCTAssertEqual(field, expected, file: file, line: line)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user